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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:07:42 +09:00
parent 8ea0db9245
commit 40766d805d
21 changed files with 2782 additions and 99 deletions
+13
View File
@@ -29,6 +29,19 @@ def get_store(request: Request) -> Any:
return getattr(request.app.state, "cafe24_store", None)
def read_lag_grace_minutes() -> int:
"""카페24 읽기 지연 유예시간(분). `CAFE24_READ_LAG_GRACE_MIN`, 기본 360.
이 시간 안에 우리가 쓴 값이 있으면, 카페24 GET 이 예전 값을 돌려줘도 우리
마지막 쓰기를 화면·적용 기준으로 삼는다(store.resolve_description 참고).
"""
import os # noqa: WPS433
from . import store # noqa: WPS433
return store.parse_grace_minutes(os.getenv("CAFE24_READ_LAG_GRACE_MIN"))
def require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
+124
View File
@@ -41,6 +41,18 @@ _TOKEN_FIELDS: tuple[str, ...] = (
)
def _flag_bool(value: Any, default: bool) -> bool:
"""카페24 'T'/'F' 또는 bool → bool."""
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "1"):
return True
if text in ("F", "FALSE", "0"):
return False
return default
class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
@@ -263,6 +275,118 @@ class Cafe24Store:
).fetchone()
return self._serialize(row)
# ── 쓰기 직후 스냅샷 (읽기 지연 보정용) ──
# 카페24 PUT 응답의 상품 값을 남겨 둔다. GET 이 아직 예전 레코드를 돌려주는
# 동안(updated_date 가 스냅샷보다 이전) 이 값으로 화면을 덮어씌운다.
# 마이그레이션 004 의 두 컬럼(last_write_snapshot, last_written_at)을 쓴다.
# 스냅샷 JSON 모양:
# {"product": {"data": {...store.SNAPSHOT_FIELDS...}, "written_at": ISO},
# "options": {"data": {...GET/PUT options 응답...}, "written_at": ISO},
# "variants": {"data": {variant_code: {...}}, "written_at": ISO}}
# 섹션별로 따로 갱신한다(가격만 바꿨는데 옵션 스냅샷이 사라지면 안 된다).
def save_write_snapshot(self, product_no: int, section: str, data: Any) -> None:
from psycopg.types.json import Jsonb # noqa: WPS433
no = int(product_no)
if no <= 0 or section not in ("product", "options", "variants"):
return
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s FOR UPDATE",
(no,),
).fetchone()
current = dict(row["last_write_snapshot"]) if row and row.get("last_write_snapshot") else {}
if section == "variants" and isinstance(data, dict):
# 품목은 코드별로 누적 병합 — 일부만 바꿔도 이전에 바꾼 것을 잃지 않게.
merged = dict((current.get("variants") or {}).get("data") or {})
merged.update(data)
data = merged
current[section] = {
"data": data,
"written_at": datetime.now(KST).isoformat(timespec="seconds"),
}
product = data if section == "product" and isinstance(data, dict) else {}
conn.execute(
"""
INSERT INTO cafe24_products
(product_no, product_code, product_name, display, selling,
last_synced_at, last_write_snapshot, last_written_at)
VALUES (%s, %s, %s, %s, %s, now(), %s, now())
ON CONFLICT (product_no) DO UPDATE SET
product_code = COALESCE(NULLIF(EXCLUDED.product_code, ''), cafe24_products.product_code),
product_name = COALESCE(NULLIF(EXCLUDED.product_name, ''), cafe24_products.product_name),
display = CASE WHEN %s THEN EXCLUDED.display ELSE cafe24_products.display END,
selling = CASE WHEN %s THEN EXCLUDED.selling ELSE cafe24_products.selling END,
last_synced_at = now(),
last_write_snapshot = EXCLUDED.last_write_snapshot,
last_written_at = now()
""",
(
no,
str(product.get("product_code") or ""),
str(product.get("product_name") or ""),
_flag_bool(product.get("display"), True),
_flag_bool(product.get("selling"), True),
Jsonb(current),
bool(product),
bool(product),
),
)
def get_write_snapshot(self, product_no: int) -> dict[str, Any]:
"""섹션별 스냅샷 dict. 없으면 {}."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s",
(int(product_no),),
).fetchone()
if not row or not row.get("last_write_snapshot"):
return {}
return dict(row["last_write_snapshot"])
# ── 읽기 지연 판정용 revision 조회 ──
def latest_write_revision(self, product_no: int, *, since: datetime) -> dict[str, Any] | None:
"""유예시간 안의 가장 최근 '쓰기' revision(MANUAL/SCHEDULED/ROLLBACK) — HTML 포함."""
with self._pool.connection() as conn:
row = conn.execute(
"""
SELECT id, product_no, revision_type, html_content, memo, created_by, created_at
FROM cafe24_product_revisions
WHERE product_no = %s
AND revision_type = ANY(%s)
AND created_at >= %s
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(int(product_no), list(store.WRITE_REVISION_TYPES), since),
).fetchone()
if not row:
return None
out = dict(row)
at = out.get("created_at")
if isinstance(at, datetime) and at.tzinfo is None:
out["created_at"] = at.replace(tzinfo=KST)
return out
def revision_digests(self, product_no: int, *, since: datetime) -> set[str]:
"""유예시간 안의 revision 들의 내용 해시(store.content_digest 와 같은 md5 hex).
내용을 통째로 옮기지 않고 DB 에서 해시만 계산한다(상세페이지는 수 MB 일 수 있다).
md5 를 쓰는 이유: 모든 PostgreSQL 버전에 있고, 여기서는 충돌 저항이 아니라
"같은 내용인가"만 필요하다.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT md5(html_content) AS digest
FROM cafe24_product_revisions
WHERE product_no = %s AND created_at >= %s
""",
(int(product_no), since),
).fetchall()
return {str(r["digest"]) for r in rows if r.get("digest")}
# ════════════════════════════════════════════════════════════
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
+4
View File
@@ -10,6 +10,8 @@
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
routes_product_info 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
routes_system 연결(OAuth)·상태·API 로그·작업 로그
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
@@ -24,6 +26,7 @@ import logging
from fastapi import APIRouter
from .routes_design_files import design_files_router
from .routes_product_info import product_info_router
from .routes_products import products_router
from .routes_schedules import schedules_router
from .routes_system import system_router
@@ -33,6 +36,7 @@ logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router)
router.include_router(product_info_router)
router.include_router(schedules_router)
router.include_router(design_files_router)
router.include_router(system_router)
+498
View File
@@ -0,0 +1,498 @@
"""카페24 상품 정보 패널 — 오른쪽 칸 JSON API.
화면(products.html 의 `_side.html` 조각)이 fetch 로 부른다. 전부 `def`(동기) 핸들러.
POST /products/{no}/basic 상품명 · 판매가 · 공급가 · 소비자가
POST /products/{no}/image 대표 이미지 교체 (multipart 파일)
GET /products/{no}/options 옵션 + 품목 조회 (읽기 지연 보정 포함)
POST /products/{no}/options 옵션 생성 (옵션 없는 상품 — 품목 자동 생성)
PUT /products/{no}/options 옵션명 · 옵션값 이름/이미지/표시방식 수정
DELETE /products/{no}/options 옵션 삭제 (품목도 함께 삭제 — 확인 후)
POST /products/{no}/options/image 옵션값 썸네일 업로드만 (경로 반환)
PUT /products/{no}/variants 품목 자체코드 · 추가금액 · 진열 · 판매
공통 규칙
- 카페24 호출은 `app.integrations.cafe24` 만 통한다(httpx 직접 호출 금지).
- 쓰기 전에 현재값을 읽되, **읽기 지연을 보정한 유효 현재값**과 비교해 바뀐
것만 보낸다(routes_products.load_product). 이걸 안 하면 "A→B 로 바꾼 직후
다시 B→A" 가 카페24의 예전 값(A)과 같다고 판단돼 무시된다.
- PUT 응답이 곧 현재값이다. 응답을 스냅샷(`save_write_snapshot`)에 남기고 화면도
응답으로 그린다 — 다시 GET 하지 않는다(GET 은 한동안 예전 값을 돌려준다).
- 모든 쓰기는 감사로그(`cafe24_audit_logs`)에 남긴다.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from . import store
from .common import read_lag_grace_minutes, require_store
from .routes_products import load_product, remember_write
logger = logging.getLogger("cafe24.product_info")
product_info_router = APIRouter()
# 업로드 허용 이미지 형식 (카페24 상품 이미지 규격)
_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
def _http_from_cafe24(exc: Cafe24Error) -> HTTPException:
return HTTPException(status_code=502, detail=str(exc))
def _read_image(upload: UploadFile) -> bytes:
"""업로드 파일 검증 + 바이트. 형식/크기 오류는 400."""
content_type = (upload.content_type or "").lower()
if content_type not in _IMAGE_TYPES:
raise HTTPException(status_code=400, detail="JPG·PNG·GIF·WEBP 이미지만 올릴 수 있습니다.")
data = upload.file.read(products.IMAGE_MAX_BYTES + 1)
if not data:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
if len(data) > products.IMAGE_MAX_BYTES:
raise HTTPException(status_code=400, detail="이미지는 10MB 를 넘을 수 없습니다.")
return data
def _scalar_view(product: dict[str, Any]) -> dict[str, Any]:
"""화면이 바로 쓰는 스칼라 값들(PUT 응답 또는 보정된 GET 에서)."""
snap = store.product_snapshot(product)
return {
"product_name": snap.get("product_name", ""),
"price": snap.get("price", ""),
"supply_price": snap.get("supply_price", ""),
"retail_price": snap.get("retail_price", ""),
"display": products._flag(snap.get("display")), # noqa: SLF001 — 같은 규칙 재사용
"selling": products._flag(snap.get("selling")), # noqa: SLF001
"detail_image": snap.get("detail_image", ""),
"list_image": snap.get("list_image", ""),
"tiny_image": snap.get("tiny_image", ""),
"small_image": snap.get("small_image", ""),
"updated_date": snap.get("updated_date", ""),
}
# ════════════════════════════════════════════════════════════
# 기본 정보 — 상품명 · 가격
# ════════════════════════════════════════════════════════════
@product_info_router.post("/products/{product_no}/basic")
def product_basic(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
st, user = require_store(request)
actor = str(user.get("email") or "")
want: dict[str, str] = {}
if "product_name" in payload:
name = str(payload.get("product_name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
if len(name) > store.NAME_MAX:
raise HTTPException(status_code=400, detail=f"상품명은 {store.NAME_MAX}자를 넘을 수 없습니다.")
want["product_name"] = name
try:
for key, label in (("price", "판매가"), ("supply_price", "공급가"), ("retail_price", "소비자가")):
if key in payload:
parsed = store.parse_price(payload.get(key), field=label)
if parsed is not None:
want[key] = parsed
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not want:
raise HTTPException(status_code=400, detail="바꿀 항목이 없습니다.")
api = build_cafe24_api(st)
try:
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}")
raise _http_from_cafe24(exc) from exc
if not current:
raise HTTPException(status_code=404, detail="카페24에서 상품을 찾지 못했습니다.")
changes: dict[str, str] = {}
before: dict[str, str] = {}
for key, value in want.items():
old = str(current.get(key) or "")
same = old == value if key == "product_name" else store.price_equal(old, value)
if not same:
changes[key] = value
before[key] = old
if not changes:
return {"ok": True, "changed": [], "product": _scalar_view(current)}
try:
updated = products.update_product(api.client, product_no, **changes)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="FAIL", detail=f"{changes} 실패: {exc}")
logger.warning("카페24 상품 %s 기본정보 변경 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
merged = {**current, **(updated if isinstance(updated, dict) else {}), **changes}
detail = ", ".join(f"{k}: '{before[k]}''{v}'" for k, v in changes.items())
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="SUCCESS", detail=detail)
logger.info("카페24 상품 %s 기본정보 변경 (%s): %s", product_no, actor, detail)
return {"ok": True, "changed": list(changes.keys()), "product": _scalar_view(merged)}
# ════════════════════════════════════════════════════════════
# 대표 이미지
# ════════════════════════════════════════════════════════════
@product_info_router.post("/products/{product_no}/image")
def product_image(
request: Request,
product_no: int,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""대표 이미지 교체. 업로드(/products/images) → PUT detail_image(A 타입).
A(대표이미지등록) 타입이면 목록/작은목록/축소 이미지는 카페24가 리사이징한다.
이전 이미지 경로는 감사로그에 남긴다(되돌리려면 그 경로를 다시 넣는다).
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
data = _read_image(file)
api = build_cafe24_api(st)
try:
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
before = str(current.get("detail_image") or "")
try:
path = products.upload_image_bytes(api.client, data)
if not path:
raise Cafe24Error("카페24가 업로드 경로를 돌려주지 않았습니다.")
updated = products.set_main_image(api.client, product_no, path)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
result="FAIL", detail=f"업로드/적용 실패: {exc}")
logger.warning("카페24 상품 %s 대표이미지 변경 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
merged = {**current, **(updated if isinstance(updated, dict) else {})}
if not merged.get("detail_image"):
merged["detail_image"] = path
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
result="SUCCESS", detail=f"'{before}''{merged.get('detail_image')}' (업로드 {path})")
logger.info("카페24 상품 %s 대표이미지 변경 (%s)", product_no, actor)
return {"ok": True, "product": _scalar_view(merged)}
# ════════════════════════════════════════════════════════════
# 옵션 · 품목
# ════════════════════════════════════════════════════════════
def _variant_view(v: dict[str, Any]) -> dict[str, Any]:
opts = v.get("options") if isinstance(v.get("options"), list) else []
return {
"variant_code": str(v.get("variant_code") or ""),
"options": [
{"name": str(o.get("name") or ""), "value": str(o.get("value") or "")}
for o in opts if isinstance(o, dict)
],
"custom_variant_code": str(v.get("custom_variant_code") or ""),
"additional_amount": str(v.get("additional_amount") or "0.00"),
"display": products._flag(v.get("display")), # noqa: SLF001
"selling": products._flag(v.get("selling")), # noqa: SLF001
"quantity": v.get("quantity"),
"use_inventory": products._flag(v.get("use_inventory"), default=False), # noqa: SLF001
"image": str(v.get("image") or ""),
}
def _options_view(option: dict[str, Any]) -> dict[str, Any]:
raw_options = option.get("options") if isinstance(option.get("options"), list) else []
out_options = []
for o in raw_options:
if not isinstance(o, dict):
continue
values = o.get("option_value") if isinstance(o.get("option_value"), list) else []
out_options.append(
{
"option_code": str(o.get("option_code") or ""),
"option_name": str(o.get("option_name") or ""),
"option_display_type": str(o.get("option_display_type") or "S"),
"required_option": str(o.get("required_option") or "T"),
"option_value": [
{
"option_text": str(v.get("option_text") or ""),
"option_image_file": str(v.get("option_image_file") or ""),
"option_link_image": str(v.get("option_link_image") or ""),
"option_color": str(v.get("option_color") or ""),
"value_no": v.get("value_no"),
}
for v in values if isinstance(v, dict)
],
}
)
return {
"has_option": products._flag(option.get("has_option"), default=False), # noqa: SLF001
"option_type": str(option.get("option_type") or ""),
"option_list_type": str(option.get("option_list_type") or ""),
"option_preset_code": str(option.get("option_preset_code") or ""),
"options": out_options,
}
def _apply_variant_snapshot(st: Any, product_no: int, variants: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""GET 품목 목록에 유예시간 안의 우리 쓰기(스냅샷)를 덮어씌운다."""
section = (st.get_write_snapshot(product_no) or {}).get("variants") or {}
data = section.get("data") or {}
if not data or not store.within_grace(section.get("written_at"), grace_minutes=read_lag_grace_minutes()):
return variants
out = []
for v in variants:
code = str(v.get("variant_code") or "")
patch = data.get(code)
out.append({**v, **patch} if isinstance(patch, dict) else v)
return out
def _apply_options_snapshot(st: Any, product_no: int, option: dict[str, Any]) -> dict[str, Any]:
"""GET 옵션이 우리 마지막 쓰기와 다르고 유예시간 안이면 스냅샷(우리 쓰기)을 쓴다."""
section = (st.get_write_snapshot(product_no) or {}).get("options") or {}
data = section.get("data")
if not isinstance(data, dict) or not store.within_grace(
section.get("written_at"), grace_minutes=read_lag_grace_minutes()
):
return option
return data
def _load_options_and_variants(st: Any, api: Any, product_no: int) -> dict[str, Any]:
option = _apply_options_snapshot(st, product_no, products.get_options(api.client, product_no))
view = _options_view(option)
variants: list[dict[str, Any]] = []
if view["has_option"]:
variants = _apply_variant_snapshot(st, product_no, products.list_variants(api.client, product_no))
return {"option": view, "variants": [_variant_view(v) for v in variants], "raw_options": option}
@product_info_router.get("/products/{product_no}/options")
def options_get(request: Request, product_no: int) -> dict[str, Any]:
st, _user = require_store(request)
api = build_cafe24_api(st)
try:
loaded = _load_options_and_variants(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
return {"ok": True, "option": loaded["option"], "variants": loaded["variants"],
"display_types": store.OPTION_DISPLAY_LABELS}
@product_info_router.post("/products/{product_no}/options")
def options_create(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""옵션 없는 상품에 조합형 옵션 1개(옵션명 + 옵션값들)를 만든다. 품목은 자동 생성."""
st, user = require_store(request)
actor = str(user.get("email") or "")
try:
body = store.build_create_options_request(
str(payload.get("option_name") or ""),
store.parse_option_values(payload.get("values")),
display_type=str(payload.get("display_type") or "S"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
api = build_cafe24_api(st)
try:
existing = products.get_options(api.client, product_no)
if products._flag(existing.get("has_option"), default=False): # noqa: SLF001
raise HTTPException(status_code=409, detail="이미 옵션이 있는 상품입니다. 수정하거나 삭제한 뒤 다시 만드세요.")
created = products.create_options(api.client, product_no, body)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="create_options", product_no=product_no,
result="FAIL", detail=f"{body['options'][0]['option_name']} 실패: {exc}")
raise _http_from_cafe24(exc) from exc
st.save_write_snapshot(product_no, "options", created)
values = [v["option_text"] for v in body["options"][0]["option_value"]]
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
try:
variants = products.list_variants(api.client, product_no)
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다.
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc)
variants = []
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
@product_info_router.put("/products/{product_no}/options")
def options_update(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""옵션명 · 옵션값 이름/썸네일/연결이미지/색상 · 표시방식 수정.
카페24 PUT 은 `original_options`(수정 전)와 `options`(수정 후)를 짝지어 받는다.
수정 전 값은 화면이 들고 있던 것이 아니라 **지금 카페24에서 다시 읽은 값**(읽기
지연 보정 후)을 쓴다 — 그래야 다른 곳에서 바뀐 이름과 어긋나지 않는다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
edited = payload.get("options")
if not isinstance(edited, list) or not edited:
raise HTTPException(status_code=400, detail="옵션 목록이 비어 있습니다.")
api = build_cafe24_api(st)
try:
loaded = _load_options_and_variants(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
if not loaded["option"]["has_option"]:
raise HTTPException(status_code=409, detail="옵션이 없는 상품입니다. 먼저 옵션을 만드세요.")
original = loaded["option"]["options"]
try:
body = store.build_update_options_request(
original, edited, option_list_type=str(payload.get("option_list_type") or "")
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if loaded["option"]["option_preset_code"]:
body["option_preset_code"] = loaded["option"]["option_preset_code"]
try:
updated = products.update_options(api.client, product_no, body)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_options", product_no=product_no,
result="FAIL", detail=str(exc))
logger.warning("카페24 상품 %s 옵션 수정 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
st.save_write_snapshot(product_no, "options", updated)
summary = "; ".join(
f"{o['option_name']}: " + ", ".join(v["option_text"] for v in o["option_value"])
for o in body["options"]
)
st.log_audit(actor=actor, action="update_options", product_no=product_no,
result="SUCCESS", detail=summary[:900])
logger.info("카페24 상품 %s 옵션 수정 (%s)", product_no, actor)
# 품목의 옵션값 표기는 이름을 바꾼 만큼 달라진다 — 응답값으로 다시 맞춘다.
variants = loaded["variants"]
rename: dict[tuple[str, str], tuple[str, str]] = {}
for o, n in zip(original, body["options"]):
for ov, nv in zip(o["option_value"], n["option_value"]):
rename[(o["option_name"], ov["option_text"])] = (n["option_name"], nv["option_text"])
for v in variants:
v["options"] = [
dict(zip(("name", "value"), rename.get((opt["name"], opt["value"]), (opt["name"], opt["value"]))))
for opt in v["options"]
]
return {"ok": True, "option": _options_view(updated), "variants": variants}
@product_info_router.delete("/products/{product_no}/options")
def options_delete(request: Request, product_no: int) -> dict[str, Any]:
"""옵션 삭제 — 카페24가 품목도 함께 지운다. 화면에서 확인창을 거친다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
api = build_cafe24_api(st)
try:
before = products.get_options(api.client, product_no)
products.delete_options(api.client, product_no)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
result="FAIL", detail=str(exc))
raise _http_from_cafe24(exc) from exc
names = ", ".join(
str(o.get("option_name") or "") for o in (before.get("options") or []) if isinstance(o, dict)
)
st.save_write_snapshot(product_no, "options", {"has_option": "F", "options": []})
st.save_write_snapshot(product_no, "variants", {})
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
result="SUCCESS", detail=f"삭제된 옵션: {names or '(없음)'}")
logger.info("카페24 상품 %s 옵션 삭제 (%s)", product_no, actor)
return {"ok": True, "option": _options_view({"has_option": "F"}), "variants": []}
@product_info_router.post("/products/{product_no}/options/image")
def option_image_upload(
request: Request,
product_no: int,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""옵션값 썸네일 업로드만 한다. 경로를 돌려주면 화면이 옵션 저장(PUT)에 담는다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
data = _read_image(file)
api = build_cafe24_api(st)
try:
path = products.upload_image_bytes(api.client, data)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
result="FAIL", detail=str(exc))
raise _http_from_cafe24(exc) from exc
if not path:
raise HTTPException(status_code=502, detail="카페24가 업로드 경로를 돌려주지 않았습니다.")
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
result="SUCCESS", detail=path)
return {"ok": True, "path": path}
@product_info_router.put("/products/{product_no}/variants")
def variants_update(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""품목 여러 건의 자체코드 · 추가금액 · 진열 · 판매를 한 번에 바꾼다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
rows = payload.get("rows")
if not isinstance(rows, list) or not rows:
raise HTTPException(status_code=400, detail="바꿀 품목이 없습니다.")
try:
requests = store.build_variant_updates(rows)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not requests:
raise HTTPException(status_code=400, detail="바뀐 값이 없습니다.")
api = build_cafe24_api(st)
try:
results = products.update_variants(api.client, product_no, requests)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
result="FAIL", detail=f"{len(requests)}건 실패: {exc}")
logger.warning("카페24 상품 %s 품목 수정 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
# 우리가 보낸 값이 곧 현재값이다(카페24가 받아들였으므로). 응답에 담긴 값이 있으면
# 그것을 우선한다.
by_code: dict[str, dict[str, Any]] = {}
for r in requests:
by_code[r["variant_code"]] = {k: v for k, v in r.items() if k != "variant_code"}
for r in results:
code = str(r.get("variant_code") or "")
if code in by_code:
for key in ("custom_variant_code", "additional_amount", "display", "selling"):
if key in r and r[key] is not None:
by_code[code][key] = r[key]
st.save_write_snapshot(product_no, "variants", by_code)
detail = "; ".join(
f"{code}: " + ", ".join(f"{k}={v}" for k, v in patch.items()) for code, patch in by_code.items()
)
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
result="SUCCESS", detail=detail[:900])
logger.info("카페24 상품 %s 품목 %s건 수정 (%s)", product_no, len(by_code), actor)
return {"ok": True, "updated": {code: _variant_view({"variant_code": code, **patch})
for code, patch in by_code.items()}}
+172 -51
View File
@@ -1,22 +1,38 @@
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널).
화면 구성
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목.
(JSON API 는 routes_product_info.py)
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 조각을
한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT
MANUAL 버전 + 감사로그
카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 →
지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
── 카페24 읽기 지연(read-after-write lag) ──
카페24 관리자 API 는 PUT 직후 한동안 GET 에서 **예전 값**을 돌려준다(실물 관찰,
몇 초에서 훨씬 길게). 우리 쪽 캐시 문제가 아니다(모든 응답 no-store). 그 값을 그대로
믿으면 "적용했는데 예전 소스가 보이고" 그 예전 값으로 지문을 만들어 다음 적용 때
충돌로 오판한다. 그래서 **마지막 쓰기가 권위**다:
- 상세설명: 카페24 값이 우리가 최근(유예시간 안)에 남긴 revision 중 하나와 같으면
"아직 예전 값" → 마지막 쓰기(MANUAL/SCHEDULED)를 보여주고 그 지문을 쓴다.
우리가 모르는 값이면 관리자에서 직접 고친 것 → 카페24 값을 믿는다.
(`store.resolve_description`, `_resolve_description`)
- 상품명/가격/이미지/진열/판매: PUT 응답을 스냅샷으로 남기고, GET 의 updated_date
가 스냅샷보다 이전이면 스냅샷으로 덮어씌운다(`store.overlay_recent_write`).
유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값.
PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
@@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode
@@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from app.timezone import now_kst
from . import store
from .common import base_ctx, guard, require_store
from .common import base_ctx, guard, read_lag_grace_minutes, require_store
logger = logging.getLogger("cafe24.products")
@@ -84,6 +102,17 @@ def _price(value: Any) -> str:
return f"{won:,}"
def _price_plain(value: Any) -> str:
"""'6900.00''6900' (입력칸 초기값용)."""
text = str(value or "").strip()
if not text:
return ""
try:
return str(int(Decimal(text)))
except (ArithmeticError, ValueError):
return text
def _short_dt(value: Any) -> str:
"""'2026-08-14T11:38:18+09:00''2026-08-14 11:38'."""
text = str(value or "").strip()
@@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str:
return urlencode(params)
# ════════════════════════════════════════════════════════════
# 읽기 지연 보정 — 상품 dict 와 상세설명에 각각 적용한다.
# 다른 라우트(routes_product_info)도 같은 함수를 쓴다.
# ════════════════════════════════════════════════════════════
def load_product(st: Any, api: Any, product_no: int) -> tuple[dict[str, Any], bool]:
"""카페24 GET + 최근 쓰기 스냅샷 덮어씌우기. (상품 dict, 스칼라 지연 여부).
지연 판정은 카페24의 updated_date 끼리 비교한다 — GET 이 PUT 응답보다 이전
레코드를 돌려주면 스냅샷 값(상품명·가격·이미지·진열/판매)을 쓴다.
"""
product = products.get_product(api.client, product_no)
if not product:
return product, False
section = (st.get_write_snapshot(product_no) or {}).get("product") or {}
merged, stale = store.overlay_recent_write(
product,
snapshot=section.get("data"),
written_at=section.get("written_at"),
grace_minutes=read_lag_grace_minutes(),
)
return merged, stale
def resolve_description(
st: Any, product_no: int, cafe24_html: str
) -> tuple[str, str, dict[str, Any] | None]:
"""(유효 HTML, 상태, 마지막 쓰기 revision). 상태는 store.SYNC_*."""
grace = read_lag_grace_minutes()
since = now_kst() - timedelta(minutes=grace)
last_write = st.latest_write_revision(product_no, since=since)
if not last_write:
return cafe24_html, store.SYNC_NONE, None
digests = st.revision_digests(product_no, since=since)
effective, state = store.resolve_description(
cafe24_html, last_write=last_write, known_digests=digests, grace_minutes=grace
)
return effective, state, last_write
def remember_write(st: Any, product_no: int, updated: dict[str, Any]) -> dict[str, Any]:
"""PUT 응답(상품 dict)을 캐시·스냅샷에 남긴다. 정규화된 캐시 행을 돌려준다."""
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
st.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
return info
def _info(product: dict[str, Any]) -> dict[str, Any]:
"""편집기·정보 패널 공용 상품 요약."""
info = products.normalize_product(product) if product else {}
return {
**info,
"price": _price(product.get("price")),
"price_plain": _price_plain(product.get("price")),
"supply_price": _price(product.get("supply_price")),
"supply_price_plain": _price_plain(product.get("supply_price")),
"retail_price": _price(product.get("retail_price")),
"retail_price_plain": _price_plain(product.get("retail_price")),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
"detail_image": str(product.get("detail_image") or ""),
"list_image": str(product.get("list_image") or ""),
"tiny_image": str(product.get("tiny_image") or ""),
"small_image": str(product.get("small_image") or ""),
}
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
"""편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회."""
api = build_cafe24_api(st)
product: dict[str, Any] = {}
desc = None
error = ""
scalar_stale = False
html_effective = ""
sync_state = store.SYNC_NONE
last_write: dict[str, Any] | None = None
try:
product = products.get_product(api.client, product_no)
product, scalar_stale = load_product(st, api, product_no)
desc = products.descriptions_from_product(product)
st.upsert_products([products.normalize_product(product)])
html_effective, sync_state, last_write = resolve_description(st, product_no, desc.description)
except Cafe24Error as exc:
error = str(exc)
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
info = products.normalize_product(product) if product else {}
return {
"product_no": product_no,
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
"product_url": api.config.product_url(product_no),
"info": {
**info,
"price": _price(product.get("price")),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
},
"info": _info(product),
"desc": desc,
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "",
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
# 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다.
"html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "",
# 지문은 **인코딩된 유효 현재값**으로 만든다(적용 직전 같은 규칙으로 계산한
# 유효 현재값과 비교하므로).
"fingerprint": store.fingerprint(html_effective) if desc else "",
"sync_state": sync_state,
"sync_pending": sync_state == store.SYNC_PENDING,
"sync_external": sync_state == store.SYNC_EXTERNAL,
"scalar_stale": scalar_stale,
"last_write_at": _short_dt(last_write.get("created_at").isoformat() if last_write and last_write.get("created_at") else ""),
"last_write_by": (last_write or {}).get("created_by") or "",
"revisions": st.list_revisions(product_no, limit=20),
"editor_error": error,
"option_display_types": store.OPTION_DISPLAY_LABELS,
}
@products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse:
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
"""3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
@@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse:
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
"page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
"rows": rows,
"total": total,
"shown": len(rows),
@@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse:
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
def product_pane(request: Request, product_no: int) -> HTMLResponse:
"""오른쪽 편집기 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
"""편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.
두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기
위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다.
"""
from app.main import render_template # noqa: WPS433
checked = guard(request)
@@ -246,11 +355,11 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
ctx = base_ctx(request, user, active_tab="products")
ctx.update(_editor_ctx(st, product_no))
ctx["list_query"] = _list_query(request)
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
return _no_store(render_template(request, "cafe24/_panes.html", ctx))
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
NAME_MAX = 250
NAME_MAX = store.NAME_MAX
@products_router.post("/products/{product_no}/name")
@@ -263,7 +372,7 @@ def product_rename(
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
(되돌리려면 로그를 보고 다시 바꾼다).
(되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
@@ -276,7 +385,7 @@ def product_rename(
api = build_cafe24_api(st)
try:
current = products.get_product(api.client, product_no)
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
@@ -298,9 +407,7 @@ def product_rename(
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
info = remember_write(st, product_no, updated)
after = str(info.get("product_name") or name)
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
@@ -348,14 +455,12 @@ def product_status(
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
if "display" not in updated or "selling" not in updated:
try:
updated = products.get_product(api.client, product_no)
updated, _ = load_product(st, api, product_no)
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
updated = {}
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
info = remember_write(st, product_no, updated)
state = {
"display": bool(info.get("display", want if field == "display" else True)),
"selling": bool(info.get("selling", want if field == "selling" else True)),
@@ -370,7 +475,7 @@ def product_status(
@products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
"""옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
@@ -387,9 +492,11 @@ def product_apply(
순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
우리 마지막 쓰기가 현재값이다)
3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
@@ -423,11 +530,15 @@ def product_apply(
)
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
# 읽기 지연 판정 — 카페24가 아직 예전 값을 돌려주면 우리 마지막 쓰기가 현재값이다.
effective, sync_state, _ = resolve_description(st, product_no, current.description)
pending = sync_state == store.SYNC_PENDING
backup_id = st.add_revision(
product_no=product_no,
html_content=current.description,
html_content=effective,
revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업",
memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
created_by=actor,
)
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
@@ -441,7 +552,7 @@ def product_apply(
created_by=actor,
)
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
if base_fingerprint and base_fingerprint != store.fingerprint(effective):
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
@@ -451,13 +562,13 @@ def product_apply(
status_code=303,
)
if submitted == current.description:
if submitted == effective:
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
try:
# mobile_description 은 보내지 않는다 — update_descriptions 가
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
products.update_descriptions(api.client, product_no, description=submitted)
updated = products.update_descriptions(api.client, product_no, description=submitted)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
@@ -469,11 +580,11 @@ def product_apply(
status_code=303,
)
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
products.wait_for_description(api.client, product_no, submitted)
# PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등).
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
# MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이
# 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다.
revision_id = st.add_revision(
product_no=product_no,
html_content=submitted,
@@ -481,13 +592,23 @@ def product_apply(
memo=memo,
created_by=actor,
)
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위
# MANUAL revision 을 기준으로 그리므로 문제없다.
confirmed = products.wait_for_description(api.client, product_no, submitted)
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=revision_id, result="SUCCESS",
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)",
detail=(
f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지, "
f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})"
),
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
logger.info("카페24 상품 %s 상세설명 적용 (%s, 재조회 확인=%s)", product_no, actor, confirmed)
note = "" if confirmed else " 카페24 관리자 API 반영은 잠시 늦을 수 있어 방금 적용한 내용을 표시합니다."
return RedirectResponse(
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}",
status_code=303,
)
+349
View File
@@ -439,3 +439,352 @@ def fingerprint(html: str) -> str:
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
"""
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
# ════════════════════════════════════════════════════════════
# 카페24 읽기 지연(read-after-write lag) 보정
#
# 실물 관찰: PUT 이 성공하고 쇼핑몰 화면에는 바로 반영되는데도, 관리자 API
# (`GET /admin/products/{no}`)는 한동안 **직전 값**을 돌려준다. 몇 초로 끝날 때도
# 있고 훨씬 길 때도 있다. 우리 쪽에는 캐시가 없으므로(no-store) 그 값을 그대로
# 보여주면 "적용했는데 예전 소스가 보이는" 증상이 된다. 더 나쁜 것은 그 예전 값으로
# 지문을 만들어 다음 적용 때 충돌로 오판하거나, 예전 값을 백업으로 남기는 것이다.
#
# 규칙: **마지막 쓰기가 권위다.**
# - 카페24 값이 마지막 쓰기와 같다 → synced (따라잡음)
# - 카페24 값이 우리가 아는 *과거 값* 중 하나 → pending (읽기 지연 — 마지막 쓰기를 보여준다)
# (최근 유예시간 안의 BACKUP/MANUAL/... revision 해시와 대조)
# - 카페24 값이 우리가 모르는 값 → external (관리자에서 직접 고침 — 카페24 값을 믿는다)
# - 최근 쓰기가 없다 → none (카페24 값 그대로)
# 유예시간(grace)이 지나면 무조건 카페24 값을 믿는다 — 지연은 영원하지 않고,
# 우리가 영원히 로컬 값을 고집하면 그것이 또 다른 캐시가 된다.
# ════════════════════════════════════════════════════════════
SYNC_SYNCED = "synced"
SYNC_PENDING = "pending"
SYNC_EXTERNAL = "external"
SYNC_NONE = "none"
# 쓰기 종류 — 이 revision 들은 "우리가 카페24에 올린 값"이다.
WRITE_REVISION_TYPES: tuple[str, ...] = (REVISION_MANUAL, REVISION_SCHEDULED, REVISION_ROLLBACK)
# 읽기 지연 유예시간 기본값(분). 환경변수 CAFE24_READ_LAG_GRACE_MIN 로 조정.
DEFAULT_READ_LAG_GRACE_MIN = 360
def parse_grace_minutes(value: object, default: int = DEFAULT_READ_LAG_GRACE_MIN) -> int:
try:
minutes = int(str(value or "").strip())
except (TypeError, ValueError):
return default
return minutes if minutes > 0 else default
def _as_aware(value: object) -> datetime | None:
"""ISO 문자열/naive datetime → KST aware datetime. 못 읽으면 None."""
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=KST)
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
def within_grace(written_at: object, *, grace_minutes: int, now: datetime | None = None) -> bool:
"""쓰기 시각이 유예시간 안인지."""
at = _as_aware(written_at)
if at is None:
return False
current = now or datetime.now(KST)
return current - at <= timedelta(minutes=max(1, int(grace_minutes)))
def resolve_description(
cafe24_html: str,
*,
last_write: dict | None,
known_digests: set[str] | frozenset[str],
grace_minutes: int,
now: datetime | None = None,
) -> tuple[str, str]:
"""(화면·적용에 쓸 유효 HTML, 상태) 를 돌려준다.
last_write: 최근 쓰기 revision — {"html_content", "created_at"}.
known_digests: 유예시간 안의 revision 들의 내용 해시(`content_digest()` 결과) 집합.
"""
if not last_write:
return cafe24_html, SYNC_NONE
if not within_grace(last_write.get("created_at"), grace_minutes=grace_minutes, now=now):
return cafe24_html, SYNC_NONE
last_html = str(last_write.get("html_content") or "")
if cafe24_html == last_html:
return cafe24_html, SYNC_SYNCED
if content_digest(cafe24_html) in known_digests:
return last_html, SYNC_PENDING
return cafe24_html, SYNC_EXTERNAL
def content_digest(html: str) -> str:
"""revision 내용 대조용 md5 hex — DB 의 `md5(html_content)` 와 같은 값.
(`fingerprint` 는 낙관적 잠금용 sha256 이고, 이것은 "우리가 아는 값인가" 대조용이다.
md5 는 모든 PostgreSQL 에 내장돼 있어 DB 쪽에서 계산할 수 있다.)
"""
return hashlib.md5((html or "").encode("utf-8")).hexdigest() # noqa: S324 — 보안 목적 아님
# 쓰기 직후 스냅샷에 보관하고, 지연 판정 시 덮어씌우는 스칼라 필드.
# description 은 넣지 않는다(크기 — revision 이 담당).
SNAPSHOT_FIELDS: tuple[str, ...] = (
"product_name",
"product_code",
"price",
"supply_price",
"retail_price",
"display",
"selling",
"detail_image",
"list_image",
"tiny_image",
"small_image",
"updated_date",
"separated_mobile_description",
)
def product_snapshot(product: dict) -> dict:
"""PUT 응답(상품 dict) → 스냅샷(JSONB 저장용). 값은 문자열/숫자/불리언만 남긴다."""
out: dict = {}
for key in SNAPSHOT_FIELDS:
if key in product and product[key] is not None:
value = product[key]
out[key] = value if isinstance(value, (bool, int, float)) else str(value)
return out
def overlay_recent_write(
fetched: dict,
*,
snapshot: dict | None,
written_at: object,
grace_minutes: int,
now: datetime | None = None,
) -> tuple[dict, bool]:
"""카페24 GET 결과가 우리 마지막 쓰기보다 오래됐으면 스냅샷 값을 덮어씌운다.
판정 근거는 카페24 자신의 `updated_date` 다 — PUT 응답의 updated_date(스냅샷)
보다 GET 의 updated_date 가 **이전**이면 GET 이 아직 예전 레코드를 돌려주는
것이다. 우리 서버 시계와 비교하지 않으므로 시계 차이에 영향받지 않는다.
날짜가 없어 판정할 수 없으면 카페24 값을 그대로 둔다.
반환: (병합된 상품 dict, 지연 여부)
"""
if not snapshot:
return fetched, False
if not within_grace(written_at, grace_minutes=grace_minutes, now=now):
return fetched, False
fetched_at = _as_aware(fetched.get("updated_date"))
snap_at = _as_aware(snapshot.get("updated_date"))
if fetched_at is None or snap_at is None or fetched_at >= snap_at:
return fetched, False
merged = dict(fetched)
for key in SNAPSHOT_FIELDS:
if key in snapshot and key != "updated_date":
merged[key] = snapshot[key]
merged["updated_date"] = snapshot.get("updated_date") or fetched.get("updated_date")
return merged, True
# ════════════════════════════════════════════════════════════
# 기본 정보(상품명·가격) 입력 검증 — 오른쪽 정보 패널
# ════════════════════════════════════════════════════════════
NAME_MAX = 250
PRICE_MAX = 2_147_483_647
def parse_price(value: object, *, field: str = "가격") -> str | None:
"""'6,900' / '6900.00' / 6900 → '6900.00'. 빈 값이면 None(변경 안 함)."""
if value is None:
return None
text = str(value).strip().replace(",", "").replace("", "")
if not text:
return None
try:
number = int(round(float(text)))
except ValueError:
raise ValueError(f"{field}은(는) 숫자여야 합니다.") from None
if number < 0 or number > PRICE_MAX:
raise ValueError(f"{field}은(는) 0 이상 {PRICE_MAX:,} 이하여야 합니다.")
return f"{number}.00"
def price_equal(a: object, b: object) -> bool:
"""카페24 '6900.00' 과 우리 '6900.00'/'6900' 을 같은 값으로 본다."""
def norm(v: object) -> str | None:
try:
return parse_price(v)
except ValueError:
return str(v)
return norm(a) == norm(b)
# ════════════════════════════════════════════════════════════
# 옵션/품목 payload — 순수 변환(검증)만. 전송은 integrations.products 가 한다.
# ════════════════════════════════════════════════════════════
OPTION_DISPLAY_TYPES: tuple[str, ...] = ("S", "P", "B", "R")
OPTION_DISPLAY_LABELS: dict[str, str] = {
"S": "셀렉트박스",
"P": "미리보기(이미지)",
"B": "텍스트버튼",
"R": "라디오버튼",
}
VARIANT_CODE_RE = re.compile(r"^[A-Z0-9]{12}$")
CUSTOM_CODE_MAX = 40
ADDITIONAL_AMOUNT_MAX = 2_147_483_647
def parse_option_values(raw: object) -> list[str]:
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
text = str(raw or "")
seen: list[str] = []
for part in re.split(r"[,\n]", text):
value = part.strip()
if value and value not in seen:
seen.append(value)
return seen
def build_create_options_request(
option_name: str, values: list[str], *, display_type: str = "S"
) -> dict:
name = (option_name or "").strip()
if not name:
raise ValueError("옵션명을 입력하세요.")
if not values:
raise ValueError("옵션값을 하나 이상 입력하세요.")
dtype = (display_type or "S").strip().upper()
if dtype not in OPTION_DISPLAY_TYPES:
dtype = "S"
return {
"has_option": "T",
"option_type": "T", # 조합형
"option_list_type": "S", # 조합 분리선택형
"options": [
{
"option_name": name,
"option_value": [{"option_text": v} for v in values],
"option_display_type": dtype,
}
],
}
def build_update_options_request(
original: list[dict], edited: list[dict], *, option_list_type: str = ""
) -> dict:
"""옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청.
카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를
**같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로
할 수 없으므로 개수가 다르면 거부한다.
original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록.
"""
if len(original) != len(edited):
raise ValueError("옵션 개수가 카페24와 다릅니다. 다시 읽은 뒤 수정하세요.")
orig_out: list[dict] = []
new_out: list[dict] = []
for o, e in zip(original, edited):
o_vals = list(o.get("option_value") or [])
e_vals = list(e.get("option_value") or [])
if len(o_vals) != len(e_vals):
raise ValueError(
f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. "
"옵션값 추가/삭제는 이 API 로 할 수 없습니다."
)
name = str(e.get("option_name") or "").strip()
if not name:
raise ValueError("옵션명은 비울 수 없습니다.")
orig_entry: dict = {
"option_name": str(o.get("option_name") or ""),
"option_value": [],
}
new_entry: dict = {"option_name": name, "option_value": []}
if o.get("option_code"):
orig_entry["option_code"] = o["option_code"]
new_entry["option_code"] = o["option_code"]
dtype = str(
e.get("option_display_type") or o.get("option_display_type") or ""
).strip().upper()
if dtype in OPTION_DISPLAY_TYPES:
new_entry["option_display_type"] = dtype
for ov, ev in zip(o_vals, e_vals):
text = str(ev.get("option_text") or "").strip()
if not text:
raise ValueError("옵션값 이름은 비울 수 없습니다.")
o_item: dict = {"option_text": str(ov.get("option_text") or "")}
n_item: dict = {"option_text": text}
if ov.get("value_no") is not None:
o_item["value_no"] = ov["value_no"]
n_item["value_no"] = ov["value_no"]
for key in ("option_image_file", "option_link_image", "option_color"):
value = ev.get(key)
if value is None:
continue
value = str(value).strip()
if value:
n_item[key] = value
orig_entry["option_value"].append(o_item)
new_entry["option_value"].append(n_item)
orig_out.append(orig_entry)
new_out.append(new_entry)
request: dict = {"original_options": orig_out, "options": new_out}
list_type = (option_list_type or "").strip().upper()
if list_type in ("S", "C"):
request["option_list_type"] = list_type
return request
def parse_additional_amount(value: object) -> str | None:
"""'1,000' / '-500''1000.00'. 빈 값은 None(변경 안 함)."""
if value is None:
return None
text = str(value).strip().replace(",", "").replace("", "")
if not text:
return None
try:
number = int(round(float(text)))
except ValueError:
raise ValueError("추가금액은 숫자여야 합니다.") from None
if abs(number) > ADDITIONAL_AMOUNT_MAX:
raise ValueError("추가금액 범위를 벗어났습니다.")
return f"{number}.00"
def build_variant_updates(rows: list[dict]) -> list[dict]:
"""화면 행 목록 → PUT /variants `requests` 배열. 바뀔 것이 없는 행은 뺀다."""
out: list[dict] = []
for row in rows:
code = str(row.get("variant_code") or "").strip().upper()
if not VARIANT_CODE_RE.match(code):
raise ValueError(f"품목코드 형식이 올바르지 않습니다: {code or '(빈 값)'}")
item: dict = {"variant_code": code}
if row.get("custom_variant_code") is not None:
custom = str(row["custom_variant_code"]).strip()
if len(custom) > CUSTOM_CODE_MAX:
raise ValueError(f"자체 품목코드는 {CUSTOM_CODE_MAX}자를 넘을 수 없습니다.")
item["custom_variant_code"] = custom
if "additional_amount" in row:
amount = parse_additional_amount(row["additional_amount"])
if amount is not None:
item["additional_amount"] = amount
for flag in ("display", "selling"):
if row.get(flag) is not None:
item[flag] = "T" if parse_tristate(row[flag]) else "F"
if len(item) > 1:
out.append(item)
return out
@@ -1,6 +1,6 @@
{# 오른쪽 편집기 조각.
{# 가운데 편집기 조각.
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
같은 조각 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
같은 조각(+ 오른쪽 정보 패널 _side.html)을 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
@@ -11,6 +11,23 @@
</div>
{% endif %}
{# 읽기 지연 배너 — 카페24 관리자 API 가 아직 이전 값을 돌려주는 동안에는 마지막으로
적용한 내용(MANUAL/SCHEDULED revision)을 편집기에 보여준다. 쇼핑몰 화면에는 이미
반영돼 있다(실물 관찰). 지문도 이 값으로 만들어 다음 적용이 충돌로 오판되지 않는다. #}
{% if sync_pending %}
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
<strong>카페24 반영 대기 중</strong> — 카페24 관리자 API 가 아직 이전 소스를 돌려주고 있어
마지막으로 적용한 내용{% if last_write_at %}({{ last_write_at }}{% if last_write_by %}, {{ last_write_by }}{% endif %}){% endif %}을 표시합니다.
쇼핑몰 화면에는 이미 반영되어 있습니다.
<button type="button" class="erp-btn erp-btn-outline cf24-btn-inline" data-reload="{{ product_no }}">다시 확인</button>
</div>
{% elif sync_external %}
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
이 상품은 최근 여기서 적용한 뒤 <strong>카페24 관리자에서 직접 수정</strong>된 것으로 보입니다.
카페24의 현재 소스를 표시합니다.
</div>
{% endif %}
<div class="cf24-editor-head">
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
@@ -0,0 +1,6 @@
{# 상품 선택 시 JS 가 가져가는 조각 묶음 — 가운데(편집기) + 오른쪽(정보 패널).
한 응답에 둘을 담아 카페24 상품 조회를 한 번만 한다. products.html 의 select()
가 [data-pane] 별로 나눠 각 칸에 끼워 넣는다. 여기에 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다). #}
<div data-pane="editor">{% include "cafe24/_editor.html" %}</div>
<div data-pane="side">{% include "cafe24/_side.html" %}</div>
@@ -0,0 +1,96 @@
{# 오른쪽 상품 정보 패널 조각.
기본 정보(상품명·판매가·공급가·소비자가) · 대표 이미지 · 옵션/품목.
전체 페이지(products.html)가 include 하고, 상품을 바꾸면 JS 가 /products/{no}/pane
에서 같은 조각을 다시 받아 끼워 넣는다. 그래서 <script> JS
products.html 의 cf24BindSide() 에 있다. 쓰기는 전부 routes_product_info.py 의
JSON API 로 하고, 화면은 **응답값**으로 다시 그린다(요청값을 낙관 반영하지 않는다). #}
{% if desc %}
<div class="cf24-side" id="cf24-side" data-product-no="{{ product_no }}">
{# ── 기본 정보 ── #}
<section class="cf24-side-sec">
<h4 class="cf24-side-title">기본 정보</h4>
{% if scalar_stale %}
<p class="cf24-side-note cf24-side-note-warn">
카페24 관리자 API 가 아직 이전 값을 돌려주고 있어 마지막으로 적용한 값을 표시합니다.
</p>
{% endif %}
<form class="cf24-side-form" id="cf24-basic-form" autocomplete="off">
<label class="cf24-side-field">
<span>상품명</span>
<input type="text" name="product_name" maxlength="250" value="{{ info.product_name }}" />
</label>
<div class="cf24-side-grid3">
<label class="cf24-side-field">
<span>판매가</span>
<input type="text" name="price" inputmode="numeric" value="{{ info.price_plain }}" placeholder="원" />
</label>
<label class="cf24-side-field">
<span>공급가</span>
<input type="text" name="supply_price" inputmode="numeric" value="{{ info.supply_price_plain }}" placeholder="원" />
</label>
<label class="cf24-side-field">
<span>소비자가</span>
<input type="text" name="retail_price" inputmode="numeric" value="{{ info.retail_price_plain }}" placeholder="원" />
</label>
</div>
<div class="cf24-side-actions">
<span class="cf24-muted" id="cf24-basic-msg"></span>
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-basic-save">카페24에 저장</button>
</div>
</form>
</section>
{# ── 대표 이미지 ── #}
<section class="cf24-side-sec">
<h4 class="cf24-side-title">대표 이미지</h4>
<div class="cf24-image-box" id="cf24-image-box">
{% if info.detail_image %}
<a href="{{ info.detail_image }}" target="_blank" rel="noopener noreferrer" id="cf24-image-link">
<img class="cf24-image-main" id="cf24-image-main" src="{{ info.detail_image }}" alt="대표 이미지" />
</a>
{% else %}
<a href="#" target="_blank" rel="noopener noreferrer" id="cf24-image-link" class="is-hidden">
<img class="cf24-image-main" id="cf24-image-main" src="" alt="대표 이미지" />
</a>
<div class="cf24-image-empty" id="cf24-image-empty">등록된 대표 이미지가 없습니다.</div>
{% endif %}
</div>
<div class="cf24-image-thumbs" id="cf24-image-thumbs">
{# 이미지가 없는 종류는 칸째로 숨긴다(빈 칸에 이름만 남으면 어색하다).
업로드 뒤 응답에 경로가 오면 JS 가 칸을 다시 보인다. #}
{% for key, label in (("list_image", "목록"), ("tiny_image", "작은목록"), ("small_image", "축소")) %}
<figure class="cf24-image-thumb{% if not info[key] %} is-hidden{% endif %}" data-image-figure="{{ key }}">
<img src="{{ info[key] }}" alt="{{ label }}" data-image-key="{{ key }}" />
<figcaption>{{ label }}</figcaption>
</figure>
{% endfor %}
</div>
<form class="cf24-side-form" id="cf24-image-form">
<label class="cf24-file-pick">
<input type="file" name="file" id="cf24-image-file" accept="image/jpeg,image/png,image/gif,image/webp" />
<span class="erp-btn erp-btn-outline">파일 선택</span>
<span class="cf24-file-name" id="cf24-image-filename">선택된 파일 없음</span>
</label>
<div class="cf24-side-actions">
<span class="cf24-muted" id="cf24-image-msg">JPG·PNG·GIF·WEBP, 10MB 이하. 목록/축소 이미지는 카페24가 자동 생성합니다.</span>
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-image-save" disabled>업로드하여 교체</button>
</div>
</form>
</section>
{# ── 옵션 / 품목 — 펼칠 때 JSON 으로 불러온다(카페24 호출 2회) ── #}
<details class="cf24-side-sec cf24-side-details" id="cf24-options-details">
<summary class="cf24-side-title">옵션 / 품목 <span class="cf24-muted" id="cf24-options-count"></span></summary>
<div class="cf24-options-body" id="cf24-options-body">
<p class="cf24-muted">불러오는 중…</p>
</div>
</details>
</div>
{% else %}
<div class="cf24-side cf24-side-empty">
<p class="cf24-muted">카페24 조회에 실패해 상품 정보를 표시할 수 없습니다.</p>
</div>
{% endif %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260918a" />
{% endblock %}
{% block content %}
@@ -18,7 +18,7 @@
</div>
{% endif %}
<div class="cf24-split">
<div class="cf24-split cf24-split-3">
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
<aside class="erp-card cf24-pane cf24-pane-list">
@@ -83,7 +83,7 @@
</div>
</aside>
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
{# ── 가운데: 상세페이지 편집 ──────────────────────────────── #}
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
{% if selected %}
{% include "cafe24/_editor.html" %}
@@ -97,6 +97,17 @@
</div>
{% endif %}
</section>
{# ── 오른쪽: 상품 정보 패널(상품명·가격 / 대표 이미지 / 옵션·품목) ── #}
<section class="erp-card cf24-pane cf24-pane-side" id="cf24-side-pane">
{% if selected %}
{% include "cafe24/_side.html" %}
{% else %}
<div class="cf24-empty-pane">
<p class="cf24-muted">상품을 선택하면 상품명·가격, 대표 이미지, 옵션/품목을 여기서 수정할 수 있습니다.</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}
@@ -374,15 +385,15 @@
});
});
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다.
var reloadBtn = pane.querySelector("[data-reload]");
if (reloadBtn) {
// 「다시 읽기」/「다시 확인」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로
// 다시 받는다(도구 막대 버튼 + 반영 대기 배너 버튼, 둘 다 같은 동작).
pane.querySelectorAll("[data-reload]").forEach(function (reloadBtn) {
reloadBtn.addEventListener("click", function () {
confirmLeave().then(function (ok) {
if (ok) select(reloadBtn.dataset.reload, false);
});
});
}
});
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
@@ -647,6 +658,509 @@
}
};
/* ════════════════════════════════════════════════════════════
오른쪽 상품 정보 패널 — 상품명·가격 / 대표 이미지 / 옵션·품목
쓰기는 전부 routes_product_info.py 의 JSON API. 화면은 **응답값**으로 그린다
(요청값을 낙관 반영하면 실패했을 때 카페24와 어긋난다). 카페24 GET 은 쓰기
직후 한동안 예전 값을 돌려주므로, 저장 뒤에 다시 GET 하지 않는다.
════════════════════════════════════════════════════════════ */
function escHtml(text) {
return String(text == null ? "" : text).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function apiJson(method, url, body) {
var opts = { method: method, credentials: "same-origin", cache: "no-store", headers: {} };
if (body instanceof FormData) {
opts.body = body;
} else if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) throw new Error(data.detail || "HTTP " + res.status);
return data;
});
});
}
function wonText(value) {
var n = parseInt(String(value || "").replace(/,/g, ""), 10);
return isNaN(n) ? "" : n.toLocaleString("ko-KR") + "원";
}
function bust(url) {
// 같은 경로에 새 이미지가 올라가면 브라우저가 옛 그림을 재사용한다 — 시각을 붙인다.
if (!url) return "";
return url + (url.indexOf("?") === -1 ? "?" : "&") + "t=" + Date.now();
}
function setMsg(el, text, kind) {
if (!el) return;
el.textContent = text || "";
el.classList.remove("is-ok", "is-err");
if (kind) el.classList.add(kind);
}
// 입력칸의 값이 처음 값(defaultValue)과 다르면 표시한다.
function trackDirty(container) {
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
var mark = function () {
var base = el.tagName === "SELECT" ? el.dataset.initial : el.defaultValue;
el.classList.toggle("is-dirty", el.value !== (base == null ? "" : base));
};
if (el.tagName === "SELECT" && el.dataset.initial == null) el.dataset.initial = el.value;
el.addEventListener("input", mark);
el.addEventListener("change", mark);
});
}
function commitValues(container) {
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
if (el.tagName === "SELECT") el.dataset.initial = el.value;
else el.defaultValue = el.value;
el.classList.remove("is-dirty");
});
}
window.cf24BindSide = function () {
var sideRoot = document.getElementById("cf24-side-pane");
var root = sideRoot && sideRoot.querySelector("#cf24-side");
if (!root) return;
var no = root.dataset.productNo;
// ── 기본 정보: 상품명 · 판매가 · 공급가 · 소비자가 ──
(function setupBasic() {
var form = root.querySelector("#cf24-basic-form");
if (!form) return;
var msg = form.querySelector("#cf24-basic-msg");
var btn = form.querySelector("#cf24-basic-save");
trackDirty(form);
function paintTitle(name) {
var text = document.getElementById("cf24-name-text");
if (text) text.textContent = name;
var input = document.getElementById("cf24-name-input");
if (input) input.value = name;
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
if (!tr) return;
tr.dataset.name = name;
var cell = tr.querySelector(".cf24-col-name");
if (!cell) return;
cell.title = name;
var link = cell.querySelector("a");
(link || cell).textContent = name;
}
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {};
var labels = { product_name: "상품명", price: "판매가", supply_price: "공급가", retail_price: "소비자가" };
var lines = [];
form.querySelectorAll("input[type='text']").forEach(function (el) {
if (el.value !== el.defaultValue) {
payload[el.name] = el.value.trim();
lines.push(labels[el.name] + ": " + (el.defaultValue || "(없음)") + " → " + (el.value.trim() || "(없음)"));
}
});
if (!lines.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
if (payload.product_name === "") {
window.erpAlert("상품명은 비울 수 없습니다.");
return;
}
window.erpConfirm("다음 항목을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.join("\n"))
.then(function (ok) {
if (!ok) return;
btn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/basic", payload)
.then(function (data) {
var p = data.product || {};
form.querySelector('[name="product_name"]').value = p.product_name || "";
form.querySelector('[name="price"]').value = p.price ? String(parseInt(p.price, 10)) : "";
form.querySelector('[name="supply_price"]').value = p.supply_price ? String(parseInt(p.supply_price, 10)) : "";
form.querySelector('[name="retail_price"]').value = p.retail_price ? String(parseInt(p.retail_price, 10)) : "";
commitValues(form);
if (p.product_name) paintTitle(p.product_name);
setMsg(msg, (data.changed && data.changed.length) ? "카페24에 반영했습니다." : "변경된 값이 없어 보내지 않았습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { btn.disabled = false; });
});
});
})();
// ── 대표 이미지 ──
(function setupImage() {
var form = root.querySelector("#cf24-image-form");
if (!form) return;
var fileEl = form.querySelector("#cf24-image-file");
var nameEl = form.querySelector("#cf24-image-filename");
var msg = form.querySelector("#cf24-image-msg");
var btn = form.querySelector("#cf24-image-save");
var img = root.querySelector("#cf24-image-main");
var link = root.querySelector("#cf24-image-link");
var empty = root.querySelector("#cf24-image-empty");
function paint(p) {
if (p.detail_image && img) {
img.src = bust(p.detail_image);
if (link) { link.href = p.detail_image; link.classList.remove("is-hidden"); }
if (empty) empty.classList.add("is-hidden");
}
root.querySelectorAll("[data-image-key]").forEach(function (el) {
var v = p[el.dataset.imageKey];
if (!v) return;
el.src = bust(v);
var fig = el.closest("[data-image-figure]");
if (fig) fig.classList.remove("is-hidden");
});
}
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
nameEl.textContent = f ? f.name + " (" + Math.round(f.size / 1024) + "KB)" : "선택된 파일 없음";
btn.disabled = !f;
if (f && f.size > 10 * 1024 * 1024) {
setMsg(msg, "10MB 를 넘는 파일은 올릴 수 없습니다.", "is-err");
btn.disabled = true;
return;
}
if (f && img) {
// 업로드 전 미리보기 — 실제 교체는 응답을 받은 뒤 서버 경로로 다시 그린다.
img.src = URL.createObjectURL(f);
if (link) link.classList.remove("is-hidden");
if (empty) empty.classList.add("is-hidden");
setMsg(msg, "미리보기입니다. 「업로드하여 교체」를 누르면 카페24에 반영됩니다.", "");
}
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var f = fileEl.files && fileEl.files[0];
if (!f) return;
window.erpConfirm("대표 이미지를 「" + f.name + "」(으)로 교체합니다.\n카페24 쇼핑몰에 바로 반영되며 목록/축소 이미지도 함께 바뀝니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var fd = new FormData();
fd.append("file", f);
btn.disabled = true;
setMsg(msg, "업로드 중… (파일 크기에 따라 수십 초 걸릴 수 있습니다)", "");
apiJson("POST", "/cafe24/products/" + no + "/image", fd)
.then(function (data) {
paint(data.product || {});
fileEl.value = "";
nameEl.textContent = "선택된 파일 없음";
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); btn.disabled = false; });
});
});
})();
// ── 옵션 / 품목 — 펼칠 때 불러온다 ──
(function setupOptions() {
var details = root.querySelector("#cf24-options-details");
var body = root.querySelector("#cf24-options-body");
var count = root.querySelector("#cf24-options-count");
if (!details || !body) return;
var loaded = false;
var state = { option: null, variants: [], displayTypes: {} };
details.addEventListener("toggle", function () {
if (details.open && !loaded) { loaded = true; load(); }
});
function load() {
body.innerHTML = '<p class="cf24-muted">불러오는 중…</p>';
apiJson("GET", "/cafe24/products/" + no + "/options")
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
state.displayTypes = data.display_types || {};
render();
})
.catch(function (err) {
loaded = false;
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
body.querySelector("#cf24-opt-retry").addEventListener("click", function () { loaded = true; load(); });
});
}
function typeSelect(current, name) {
var out = '<select name="' + name + '">';
Object.keys(state.displayTypes).forEach(function (k) {
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
});
return out + "</select>";
}
function render() {
var opt = state.option || { has_option: false, options: [] };
var variants = state.variants || [];
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
if (!opt.has_option) {
body.innerHTML =
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
"</form>";
var form = body.querySelector("#cf24-opt-create");
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {
option_name: form.querySelector('[name="option_name"]').value.trim(),
values: form.querySelector('[name="values"]').value,
display_type: form.querySelector('[name="display_type"]').value
};
if (!payload.option_name || !payload.values.trim()) {
window.erpAlert("옵션명과 옵션값을 입력하세요.");
return;
}
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var msg = form.querySelector("#cf24-opt-msg");
setMsg(msg, "생성 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
});
return;
}
// 옵션 정의 편집
var html = '<form class="cf24-side-form" id="cf24-opt-form">';
opt.options.forEach(function (o, gi) {
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
'<div class="cf24-opt-head">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
"</div>";
o.option_value.forEach(function (v, vi) {
var thumb = v.option_image_file
? '<img class="cf24-opt-thumb" src="' + escHtml(v.option_image_file) + '" alt="" />'
: '<div class="cf24-opt-thumb-empty">없음</div>';
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
"</div>";
});
html += "</div>";
});
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
"</form>";
// 품목 표
html += '<div class="cf24-opt-group"><h5 class="cf24-side-title">품목 (' + variants.length + ')</h5>';
if (!variants.length) {
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 「옵션 / 품목」을 접었다 펴서 다시 불러오세요.</p>';
} else {
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
"<th>옵션</th><th class=\"cf24-v-code\">자체코드</th><th class=\"cf24-v-amount\">추가금액</th>" +
'<th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
variants.forEach(function (v) {
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
html += '<tr data-code="' + escHtml(v.variant_code) + '">' +
'<td class="cf24-v-name" title="' + escHtml(v.variant_code) + '">' +
(v.image ? '<img class="cf24-opt-thumb" style="width:28px;height:28px;vertical-align:middle;margin-right:4px" src="' + escHtml(v.image) + '" alt="" />' : "") +
escHtml(label) + "</td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</button></td>" +
"</tr>";
});
html += "</tbody></table></div>" +
'<div class="cf24-side-actions" style="margin-top:8px;"><span class="cf24-muted" id="cf24-var-msg"></span>' +
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
}
html += '<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></div>';
body.innerHTML = html;
var optForm = body.querySelector("#cf24-opt-form");
trackDirty(optForm);
bindOptionImages(optForm);
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
bindVariants();
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
}
function bindOptionImages(optForm) {
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
var fileEl = row.querySelector('input[type="file"]');
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
var slot = row.querySelector(".cf24-opt-thumb-slot");
var msg = optForm.querySelector("#cf24-opt-msg");
setMsg(msg, "썸네일 업로드 중…", "");
var fd = new FormData();
fd.append("file", f);
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
.then(function (data) {
row.querySelector('[name="option_image_file"]').value = data.path;
row.querySelector('[name="option_link_image"]').value = data.path;
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
.then(function () { fileEl.value = ""; });
});
});
}
function saveOptions(optForm) {
var msg = optForm.querySelector("#cf24-opt-msg");
var edited = [];
var changed = false;
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
var orig = state.option.options[gi];
var entry = {
option_name: g.querySelector('[name="option_name"]').value.trim(),
option_display_type: g.querySelector('[name="option_display_type"]').value,
option_value: []
};
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
var ov = orig.option_value[vi] || {};
var item = {
option_text: row.querySelector('[name="option_text"]').value.trim(),
option_image_file: row.querySelector('[name="option_image_file"]').value,
option_link_image: row.querySelector('[name="option_link_image"]').value,
option_color: ov.option_color || ""
};
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
item.option_link_image !== (ov.option_link_image || "")) changed = true;
entry.option_value.push(item);
});
edited.push(entry);
});
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
.then(function (data) {
state.option = data.option;
state.variants = data.variants || state.variants;
render();
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
}
function bindVariants() {
var saveBtn = body.querySelector("#cf24-var-save");
if (!saveBtn) return;
var wrap = body.querySelector(".cf24-variants-wrap");
trackDirty(wrap);
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
btn.addEventListener("click", function () {
var on = btn.dataset.on !== "1";
btn.dataset.on = on ? "1" : "0";
btn.classList.toggle("is-on", on);
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
});
});
saveBtn.addEventListener("click", function () {
var msg = body.querySelector("#cf24-var-msg");
var rows = [];
var lines = [];
wrap.querySelectorAll("tr[data-code]").forEach(function (tr) {
var row = { variant_code: tr.dataset.code };
var any = false;
var code = tr.querySelector('[name="custom_variant_code"]');
var amount = tr.querySelector('[name="additional_amount"]');
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
});
if (any) {
rows.push(row);
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
}
});
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
.then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
.then(function (data) {
var updated = data.updated || {};
Object.keys(updated).forEach(function (code) {
var u = updated[code];
var tr = wrap.querySelector('tr[data-code="' + code + '"]');
if (!tr) return;
var codeEl = tr.querySelector('[name="custom_variant_code"]');
var amountEl = tr.querySelector('[name="additional_amount"]');
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
var flag = btn.dataset.flag;
if (u[flag] === undefined) return;
var on = !!u[flag];
btn.dataset.on = on ? "1" : "0";
btn.dataset.initial = btn.dataset.on;
btn.classList.toggle("is-on", on);
btn.classList.remove("is-dirty");
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
});
// 상태 보관 — 접었다 펴도 응답값이 유지되게
state.variants.forEach(function (v) {
if (v.variant_code !== code) return;
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
if (u.display !== undefined) v.display = !!u.display;
if (u.selling !== undefined) v.selling = !!u.selling;
});
});
commitValues(wrap);
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { saveBtn.disabled = false; });
});
});
}
function deleteOptions() {
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
.then(function (ok) {
if (!ok) return;
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
apiJson("DELETE", "/cafe24/products/" + no + "/options")
.then(function (data) { state.option = data.option; state.variants = []; render(); })
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
loaded = false;
});
});
}
})();
};
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
function confirmLeave() {
@@ -654,9 +1168,13 @@
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
}
// ── 목록 클릭 → 오른쪽만 교체 ──
// ── 목록 클릭 → 가운데(편집기)·오른쪽(정보 패널)만 교체 ──
// /pane 응답 하나에 두 조각이 담겨 온다([data-pane=editor] / [data-pane=side]).
var sidePane = document.getElementById("cf24-side-pane");
function select(no, push) {
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
if (sidePane) sidePane.innerHTML = "";
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
fetch(url, { credentials: "same-origin", cache: "no-store" })
@@ -667,9 +1185,16 @@
})
.then(function (htmlText) {
if (htmlText === null) return;
pane.innerHTML = htmlText;
var holder = document.createElement("div");
holder.innerHTML = htmlText;
var editorPart = holder.querySelector('[data-pane="editor"]');
var sidePart = holder.querySelector('[data-pane="side"]');
pane.innerHTML = editorPart ? editorPart.innerHTML : htmlText;
if (sidePane) sidePane.innerHTML = sidePart ? sidePart.innerHTML : "";
window.cf24BindEditor();
window.cf24BindSide();
pane.scrollTop = 0;
if (sidePane) sidePane.scrollTop = 0;
if (push) {
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
history.pushState({ no: no }, "", target);
@@ -746,6 +1271,7 @@
});
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
window.cf24BindSide();
})();
</script>
{% endblock %}
+237
View File
@@ -802,6 +802,10 @@ class _FakeStore:
self.added.append(fields)
return 900 + len(self.added)
def save_write_snapshot(self, product_no, section, data):
self.snapshots = getattr(self, "snapshots", [])
self.snapshots.append((product_no, section, data))
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
self.finished.append(
{"id": schedule_id, "status": status, "error": error,
@@ -904,6 +908,239 @@ def test_worker_stops_when_nothing_due():
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
# ════════════════════════════════════════════════════════════
# 카페24 읽기 지연 보정 — 마지막 쓰기가 권위
# ════════════════════════════════════════════════════════════
def test_resolve_description_no_recent_write_uses_cafe24():
html, state = store.resolve_description("cafe", last_write=None, known_digests=set(), grace_minutes=60)
assert (html, state) == ("cafe", store.SYNC_NONE)
def _write(html, minutes_ago=1):
return {"html_content": html, "created_at": now_kst() - timedelta(minutes=minutes_ago)}
def test_resolve_description_synced_when_cafe24_caught_up():
html, state = store.resolve_description(
"NEW", last_write=_write("NEW"), known_digests={store.content_digest("OLD")}, grace_minutes=60
)
assert (html, state) == ("NEW", store.SYNC_SYNCED)
def test_resolve_description_pending_when_cafe24_returns_known_old_value():
# 카페24가 아직 적용 직전 값(BACKUP 으로 남긴 값)을 돌려준다 → 마지막 쓰기를 보여준다
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description("OLD", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("NEW", store.SYNC_PENDING)
def test_resolve_description_pending_for_older_known_value_too():
# 연속 두 번 적용(A→B→C) 뒤 카페24가 A 를 돌려줘도 '아는 값'이므로 지연으로 본다
digests = {store.content_digest(v) for v in ("A", "B", "C")}
html, state = store.resolve_description("A", last_write=_write("C"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("C", store.SYNC_PENDING)
def test_resolve_description_external_when_unknown_value():
# 관리자에서 직접 고친 값 — 카페24를 믿는다
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description("HAND", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("HAND", store.SYNC_EXTERNAL)
def test_resolve_description_grace_expired_trusts_cafe24():
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description(
"OLD", last_write=_write("NEW", minutes_ago=500), known_digests=digests, grace_minutes=60
)
assert (html, state) == ("OLD", store.SYNC_NONE)
def test_resolve_description_accepts_iso_created_at():
lw = {"html_content": "NEW", "created_at": (now_kst() - timedelta(minutes=2)).isoformat()}
html, state = store.resolve_description("OLD", last_write=lw, known_digests={store.content_digest("OLD")}, grace_minutes=60)
assert (html, state) == ("NEW", store.SYNC_PENDING)
def test_parse_grace_minutes():
assert store.parse_grace_minutes("30") == 30
assert store.parse_grace_minutes("") == store.DEFAULT_READ_LAG_GRACE_MIN
assert store.parse_grace_minutes("-5") == store.DEFAULT_READ_LAG_GRACE_MIN
assert store.parse_grace_minutes("abc") == store.DEFAULT_READ_LAG_GRACE_MIN
def test_overlay_recent_write_uses_snapshot_when_get_is_older():
fetched = {"product_name": "OLD", "price": "1000.00", "updated_date": "2026-09-18T10:00:00+09:00",
"description": "keep"}
snap = {"product_name": "NEW", "price": "2000.00", "updated_date": "2026-09-18T10:05:00+09:00"}
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
assert stale is True
assert merged["product_name"] == "NEW" and merged["price"] == "2000.00"
assert merged["description"] == "keep" # 스냅샷에 없는 필드는 그대로
assert merged["updated_date"] == snap["updated_date"]
def test_overlay_recent_write_keeps_cafe24_when_caught_up():
fetched = {"product_name": "NEWER", "updated_date": "2026-09-18T10:06:00+09:00"}
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
assert stale is False and merged is fetched
def test_overlay_recent_write_skips_without_dates_or_snapshot():
fetched = {"product_name": "X"}
assert store.overlay_recent_write(fetched, snapshot=None, written_at=now_kst(), grace_minutes=60) == (fetched, False)
snap = {"product_name": "NEW"}
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) == (fetched, False)
def test_overlay_recent_write_respects_grace():
fetched = {"product_name": "OLD", "updated_date": "2026-09-18T10:00:00+09:00"}
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
old = (now_kst() - timedelta(hours=10)).isoformat()
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=old, grace_minutes=60) == (fetched, False)
def test_product_snapshot_keeps_only_scalar_fields():
snap = store.product_snapshot({"product_name": "A", "price": "1.00", "description": "<big>", "updated_date": None, "display": "T"})
assert snap == {"product_name": "A", "price": "1.00", "display": "T"}
# ════════════════════════════════════════════════════════════
# 기본 정보 · 옵션 · 품목 입력 검증
# ════════════════════════════════════════════════════════════
def test_parse_price():
assert store.parse_price("6,900") == "6900.00"
assert store.parse_price("6900.00") == "6900.00"
assert store.parse_price(6900) == "6900.00"
assert store.parse_price("") is None
assert store.parse_price(None) is None
for bad in ("abc", "-1"):
try:
store.parse_price(bad)
except ValueError:
pass
else:
raise AssertionError(bad)
assert store.price_equal("6900.00", "6900") and not store.price_equal("6900.00", "7000")
def test_update_payload_price_and_image():
payload = products.build_update_payload(price="1000.00", supply_price="500.00", detail_image="/web/x.jpg")
assert payload["request"] == {
"price": "1000.00", "supply_price": "500.00", "detail_image": "/web/x.jpg", "image_upload_type": "A",
}
def test_parse_option_values():
assert store.parse_option_values("빨강, 파랑\n노랑,, 빨강 ") == ["빨강", "파랑", "노랑"]
def test_build_create_options_request():
body = store.build_create_options_request("색상", ["빨강", "파랑"], display_type="p")
assert body["has_option"] == "T" and body["option_type"] == "T"
assert body["options"][0]["option_display_type"] == "P"
assert [v["option_text"] for v in body["options"][0]["option_value"]] == ["빨강", "파랑"]
for name, values in (("", ["a"]), ("x", [])):
try:
store.build_create_options_request(name, values)
except ValueError:
pass
else:
raise AssertionError((name, values))
def test_build_update_options_request_pairs_original_and_new():
original = [{"option_code": "O1", "option_name": "Color", "option_display_type": "S",
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
edited = [{"option_name": "Colors", "option_display_type": "P",
"option_value": [{"option_text": "Jet Black", "option_image_file": "https://d/x.png"},
{"option_text": "Deep Red"}]}]
body = store.build_update_options_request(original, edited, option_list_type="S")
assert body["option_list_type"] == "S"
assert body["original_options"] == [{"option_code": "O1", "option_name": "Color",
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
assert body["options"][0]["option_name"] == "Colors"
assert body["options"][0]["option_display_type"] == "P"
assert body["options"][0]["option_value"][0] == {"option_text": "Jet Black", "value_no": 1, "option_image_file": "https://d/x.png"}
assert body["options"][0]["option_value"][1] == {"option_text": "Deep Red", "value_no": 2}
def test_build_update_options_request_rejects_count_mismatch():
original = [{"option_name": "A", "option_value": [{"option_text": "1"}]}]
for edited in ([], [{"option_name": "A", "option_value": []}], [{"option_name": "", "option_value": [{"option_text": "1"}]}]):
try:
store.build_update_options_request(original, edited)
except ValueError:
pass
else:
raise AssertionError(edited)
def test_build_variant_updates():
rows = [
{"variant_code": "p000000r000a", "custom_variant_code": " ABC ", "additional_amount": "1,000", "display": "on"},
{"variant_code": "P000000R000B"}, # 바뀐 것 없음 → 제외
{"variant_code": "P000000R000C", "selling": "off", "additional_amount": ""},
]
out = store.build_variant_updates(rows)
assert out == [
{"variant_code": "P000000R000A", "custom_variant_code": "ABC", "additional_amount": "1000.00", "display": "T"},
{"variant_code": "P000000R000C", "selling": "F"},
]
try:
store.build_variant_updates([{"variant_code": "bad"}])
except ValueError:
pass
else:
raise AssertionError("bad code accepted")
class _RouteClient:
"""경로별 응답을 돌려주는 가짜 클라이언트 (get/put/post/delete)."""
def __init__(self, routes):
self.routes = routes
self.calls: list[tuple] = []
def _call(self, method, path, json=None, **_kw):
self.calls.append((method, path, json))
return self.routes.get(f"{method} {path}", {})
def get(self, path, **kw):
return self._call("GET", path, **kw)
def put(self, path, **kw):
return self._call("PUT", path, **kw)
def post(self, path, **kw):
return self._call("POST", path, **kw)
def delete(self, path, **kw):
return self._call("DELETE", path, **kw)
def test_upload_images_and_variants_wrappers():
client = _RouteClient({
"POST /admin/products/images": {"images": [{"path": "https://d/a.png"}, {"path": "https://d/b.png"}]},
"PUT /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A", "display": "F"}]},
"GET /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A"}]},
"GET /admin/products/7/options": {"option": {"has_option": "T", "options": []}},
"DELETE /admin/products/7/options": {"option": {"product_no": 7}},
})
assert products.upload_images(client, ["AAA", "BBB"]) == ["https://d/a.png", "https://d/b.png"]
assert products.upload_image_bytes(client, b"\x89PNG") == "https://d/a.png"
assert products.list_variants(client, 7) == [{"variant_code": "P000000R000A"}]
assert products.get_options(client, 7)["has_option"] == "T"
assert products.update_variants(client, 7, [{"variant_code": "P000000R000A", "display": "F"}]) == [
{"variant_code": "P000000R000A", "display": "F"}
]
sent = [c for c in client.calls if c[0] == "PUT"][-1]
assert sent[2]["requests"][0]["variant_code"] == "P000000R000A"
products.delete_options(client, 7)
assert client.calls[-1][0] == "DELETE"
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
+17 -1
View File
@@ -80,7 +80,7 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
# PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
products.update_product(
updated = products.update_product(
api.client,
product_no,
description=html,
@@ -88,10 +88,26 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
display=set_display,
selling=set_selling,
)
# PUT 응답은 쓰기 직후의 실제 값이다 — 스냅샷으로 남겨 화면이 GET 의 읽기 지연에
# 흔들리지 않게 한다(routes_products._load_product 가 덮어씌운다).
if isinstance(updated, dict) and updated.get("product_no"):
try:
store_db.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
except Exception: # noqa: BLE001 — 스냅샷 실패가 예약 결과를 바꾸면 안 된다.
logger.exception("예약 #%s 상품 %s 스냅샷 저장 실패", schedule_id, product_no)
if html is not None:
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
# 확인이 안 돼도 실패가 아니다 — 화면은 마지막 쓰기(SCHEDULED revision)를 기준으로
# 그린다. 여기서 SCHEDULED revision 을 남겨야 그 기준이 생긴다.
products.wait_for_description(api.client, product_no, html)
store_db.add_revision(
product_no=product_no,
html_content=html,
revision_type=store.REVISION_SCHEDULED,
memo=f"예약 #{schedule_id} 적용",
created_by=ACTOR,
)
summary = store.describe_schedule_action(
has_html=html is not None, set_display=set_display, set_selling=set_selling