"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집). 화면 구성 왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬. 오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게. 목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을 브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음 페이지에 있는 해당 상품이 빠진다. 상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을 돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다. 쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다. 카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT → MANUAL 버전 + 감사로그 로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다. 이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는 `store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로 되돌린다(왕복 보존 — store.py 주석 참고). 핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이 블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다. """ from __future__ import annotations import logging from typing import Any from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products from . import store from .common import base_ctx, guard logger = logging.getLogger("cafe24.products") products_router = APIRouter() def _checked(request: Request, name: str) -> bool: """체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다.""" return request.query_params.get(name) is not None def _short_dt(value: Any) -> str: """'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'.""" text = str(value or "").strip() if not text: return "" return text.replace("T", " ")[:16] def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]: """왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정.""" normalized = products.normalize_product(raw) return { "product_no": normalized["product_no"], "product_name": normalized["product_name"], "display": normalized["display"], "selling": normalized["selling"], "updated_date": _short_dt(raw.get("updated_date")), } def _list_query(request: Request, *, selected: int | None = None) -> str: """현재 검색·필터를 유지한 목록 URL 쿼리스트링.""" params: list[tuple[str, str]] = [] keyword = (request.query_params.get("q") or "").strip() if keyword: params.append(("q", keyword)) for flag in ("display", "selling"): if _checked(request, flag): params.append((flag, "1")) if selected: params.append(("selected", str(selected))) return urlencode(params) def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]: """오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다.""" api = build_cafe24_api(st) product: dict[str, Any] = {} desc = None error = "" try: product = products.get_product(api.client, product_no) desc = products.descriptions_from_product(product) st.upsert_products([products.normalize_product(product)]) 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, "info": { **info, "price": str(product.get("price") or ""), "updated_date": _short_dt(product.get("updated_date")), "summary_description": product.get("summary_description") or "", }, "desc": desc, # 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고 # (2) 태그마다 줄을 나눠 정리해서 보여준다. # 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다). "html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "", "html_mobile": ( store.format_html(store.decode_html_urls(desc.mobile_description)) if desc else "" ), # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). "fingerprint": store.fingerprint(desc.description) if desc else "", "revisions": st.list_revisions(product_no, limit=20), "editor_error": error, } @products_router.get("/", response_class=HTMLResponse) def product_list(request: Request) -> HTMLResponse: """2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다.""" from app.main import render_template # noqa: WPS433 checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked keyword = (request.query_params.get("q") or "").strip() only_display = _checked(request, "display") only_selling = _checked(request, "selling") api = build_cafe24_api(st) rows: list[dict[str, Any]] = [] total = 0 truncated = False error = "" try: raw_rows, truncated = products.list_all_products(api.client, product_name=keyword) st.upsert_products([products.normalize_product(r) for r in raw_rows]) total = len(raw_rows) rows = [_row_for_list(r) for r in raw_rows] # 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다). if only_display: rows = [r for r in rows if r["display"]] if only_selling: rows = [r for r in rows if r["selling"]] except Cafe24Error as exc: # 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다. error = str(exc) logger.warning("카페24 상품 목록 조회 실패: %s", exc) try: selected = int(request.query_params.get("selected") or 0) except ValueError: selected = 0 ctx = base_ctx(request, user, active_tab="products") ctx.update( { "page_title": "카페24 상품관리", "page_subtitle": "상품 상세페이지 조회·편집·예약", "rows": rows, "total": total, "shown": len(rows), "truncated": truncated, "keyword": keyword, "only_display": only_display, "only_selling": only_selling, "selected": selected, "list_query": _list_query(request), "error": error, "flash": request.query_params.get("msg", ""), "flash_error": request.query_params.get("err", ""), } ) if selected: ctx.update(_editor_ctx(st, selected)) return render_template(request, "cafe24/products.html", ctx) @products_router.get("/products/{product_no}/pane", response_class=HTMLResponse) def product_pane(request: Request, product_no: int) -> HTMLResponse: """오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.""" from app.main import render_template # noqa: WPS433 checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked ctx = base_ctx(request, user, active_tab="products") ctx.update(_editor_ctx(st, product_no)) ctx["list_query"] = _list_query(request) return render_template(request, "cafe24/_editor.html", ctx) @products_router.get("/products/{product_no}") def product_redirect(request: Request, product_no: int): """옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303) @products_router.post("/products/{product_no}/apply") def product_apply( request: Request, product_no: int, html: str = Form(""), base_fingerprint: str = Form(""), memo: str = Form(""), list_query: str = Form(""), ): """편집한 HTML 을 카페24에 즉시 적용한다. 순서를 지키는 것이 이 함수의 핵심이다. 1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다) 2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단 3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다 4) 쓰고, MANUAL 버전과 감사로그를 남긴다 PC/모바일 미분리 상품은 모바일 필드도 같은 HTML 로 맞춘다. 분리 상품은 모바일을 건드리지 않는다(화면에 별도 반영 안내를 띄운다). """ checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked actor = str(user.get("email") or "") # 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다. base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}" back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}" # 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는 # 원래의 퍼센트 인코딩으로 되돌린다. submitted = store.format_html(store.encode_html_urls(html or "")) if not submitted.strip(): return RedirectResponse( url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.", status_code=303, ) api = build_cafe24_api(st) try: current = products.fetch_descriptions(api.client, product_no) except Cafe24Error as exc: st.log_audit( actor=actor, action="apply_description", product_no=product_no, result="FAIL", detail=f"현재값 조회 실패: {exc}", ) return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) backup_id = st.add_revision( product_no=product_no, html_content=current.description, revision_type=store.REVISION_BACKUP, memo="적용 직전 자동 백업", created_by=actor, ) if base_fingerprint and base_fingerprint != store.fingerprint(current.description): st.log_audit( actor=actor, action="apply_description", product_no=product_no, revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨", ) return RedirectResponse( url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", status_code=303, ) # 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다). # 분리 상품은 모바일을 건드리지 않는다(화면에 별도 반영 안내를 띄운다). mobile_html = None if current.separated_mobile else submitted if submitted == current.description and ( mobile_html is None or mobile_html == current.mobile_description ): return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) try: products.update_descriptions( api.client, product_no, description=submitted, mobile_description=mobile_html ) except Cafe24Error as exc: st.log_audit( actor=actor, action="apply_description", product_no=product_no, revision_id=backup_id, result="FAIL", detail=str(exc), ) logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc) return RedirectResponse( url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", status_code=303, ) revision_id = st.add_revision( product_no=product_no, html_content=submitted, revision_type=store.REVISION_MANUAL, memo=memo, created_by=actor, ) st.log_audit( actor=actor, action="apply_description", product_no=product_no, revision_id=revision_id, result="SUCCESS", detail=f"{len(submitted)}자 적용 (백업 {backup_id}" + (", 모바일 동시 반영)" if mobile_html is not None else ")"), ) logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) return RedirectResponse( url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", status_code=303, )