"""카페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에 올라간 값"으로 가정하지 않는다. PC/모바일은 구분하지 않는다 — 적용 시 `description` 과 `mobile_description` 에 같은 HTML 을 쓴다(운영 방침). 분리 사용 상품이어도 한쪽만 바뀌는 일이 없다. 이미지 경로의 한글은 카페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, Body, Form, HTTPException, 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, require_store 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 _no_store(response: Any) -> Any: """브라우저가 이 응답을 재사용하지 못하게 한다. 편집기는 카페24의 **현재** HTML 을 보여줘야 한다. 캐시된 화면이 다시 그려지면 카페24 관리자에서 값을 바꾼 뒤에도 예전 소스가 보이고, 그것을 그대로 편집하면 남의 수정을 덮어쓴다. 조각을 가져가는 fetch 에도 `cache: "no-store"` 를 건다. """ response.headers["Cache-Control"] = "no-store, must-revalidate" response.headers["Pragma"] = "no-cache" return response 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")), } # 필터 폼이 제출됐음을 알리는 표식. # 체크박스는 해제 상태면 아무 값도 보내지 않으므로, 이것 없이는 "첫 방문"과 # "사용자가 일부러 해제함"을 구분할 수 없다(기본값이 체크라서 해제가 무시된다). _FILTER_MARK = "f" def _filter_flags(request: Request) -> tuple[bool, bool]: """(진열중만, 판매중만). 첫 방문이면 둘 다 기본 체크로 본다.""" if request.query_params.get(_FILTER_MARK) is None: return True, True return _checked(request, "display"), _checked(request, "selling") 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)) if request.query_params.get(_FILTER_MARK) is not None: # 해제 상태까지 그대로 이어지도록 표식을 함께 남긴다. params.append((_FILTER_MARK, "1")) 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, # 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인) "product_url": api.config.product_url(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 "", # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페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, only_selling = _filter_flags(request) 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 _no_store(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 _no_store(render_template(request, "cafe24/_editor.html", ctx)) @products_router.post("/products/{product_no}/status") def product_status( request: Request, product_no: int, payload: dict[str, Any] = Body(default_factory=dict), ) -> dict[str, Any]: """진열/판매 상태만 바꾼다 — 편집기 오른쪽 위 배지 클릭용(JSON API). 상세설명은 건드리지 않는다(`build_update_payload` 는 준 필드만 보낸다). 그래서 BACKUP revision 도 만들지 않는다 — 되돌릴 HTML 이 없고, 상태는 다시 눌러 되돌릴 수 있다. `value` 는 클라이언트가 **원하는 결과값**이다(현재값을 뒤집지 않는다). 화면의 배지가 카페24와 어긋나 있어도 사용자가 누른 대로 되는 편이 예측 가능하다. 응답에는 쓰기 후 카페24가 돌려준 실제 상태를 담아 화면을 그것에 맞춘다. """ st, user = require_store(request) actor = str(user.get("email") or "") field = str(payload.get("field") or "").strip() if field not in ("display", "selling"): raise HTTPException(status_code=400, detail="field 는 display 또는 selling 이어야 합니다.") want = bool(payload.get("value")) api = build_cafe24_api(st) try: updated = products.update_product(api.client, product_no, **{field: want}) except Cafe24Error as exc: st.log_audit( actor=actor, action=f"set_{field}", product_no=product_no, result="FAIL", detail=f"{want} 설정 실패: {exc}", ) logger.warning("카페24 상품 %s %s 변경 실패: %s", product_no, field, exc) raise HTTPException(status_code=502, detail=str(exc)) from exc # 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다. if "display" not in updated or "selling" not in updated: try: updated = products.get_product(api.client, 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]) state = { "display": bool(info.get("display", want if field == "display" else True)), "selling": bool(info.get("selling", want if field == "selling" else True)), } st.log_audit( actor=actor, action=f"set_{field}", product_no=product_no, result="SUCCESS", detail=f"{field}={'T' if want else 'F'}", ) logger.info("카페24 상품 %s %s=%s (%s)", product_no, field, want, actor) return {"ok": True, **state} @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 을 쓴다. 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다. """ 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, ) # 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게 # 덮어쓰므로, 백업하지 않으면 그 내용을 되찾을 방법이 없다. if current.mobile_description and current.mobile_description != current.description: st.add_revision( product_no=product_no, html_content=current.mobile_description, revision_type=store.REVISION_BACKUP, memo="적용 직전 자동 백업 (모바일 — PC와 달랐던 내용)", 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/모바일을 구분하지 않는다 — 항상 같은 내용으로 함께 쓴다(운영 방침). # 분리 사용 상품이어도 모바일에 같은 HTML 을 넣으므로 한쪽만 바뀌는 일이 없다. mobile_html = submitted if submitted == current.description and 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}, PC·모바일 동시 반영)", ) logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) return RedirectResponse( url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", status_code=303, )