feat(cafe24): 상품관리를 좌우 2분할로 — 목록(좁게) | 상세페이지 편집(넓게)

왼쪽에서 상품을 클릭하면 오른쪽에 편집기가 바로 열린다. 목록을 다시 받지 않고
오른쪽 조각만 교체한다(GET /products/{no}/pane → JS 삽입). 목록까지 다시 그리면
클릭마다 카페24 호출이 2회 더 늘어나기 때문이다. JS 가 실패하거나 없으면 각 행의
링크(/cafe24/?selected=)로 그대로 동작한다.

목록은 페이지를 없애고 전체를 한 번에 받는다(list_all_products, 1회 100개·상한
1000개). 필터·정렬을 한 페이지에만 적용하면 다음 페이지에 있는 상품이 빠져
"진열중만 보기" 가 거짓이 된다. 현재 87개라 1회 호출로 끝난다.

컬럼은 요청대로 번호·상품명·진열·판매·수정 5개다. 좁은 칸에 맞춰 진열/판매는
배지 대신 점, 수정일은 월-일만 표시하고 전체 값은 title 로 둔다. 긴 상품명은
2줄로 제한해 행 높이를 고르게 유지한다(전체 이름은 title·편집기 제목에서 확인).

진열중/판매중 체크박스는 중복 선택이 되며 둘 다 켜면 AND 다. 문서에 없는 API
필터 파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다. 제목행 클릭은
오름↔내림 토글이며 한글 정렬은 localeCompare(ko) 를 쓴다.

편집 영역을 넓게 쓰려고 이 화면에서만 .erp-page 의 max-width 를 풀었다. 이때
box-sizing:border-box 를 함께 줘야 한다 — width:100% + padding:24px 이라
max-width 만 풀면 문서 전체에 가로 스크롤이 생긴다(측정으로 확인 후 수정).

편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 저장 안 됨 경고를 띄운다.

옛 단독 화면(product.html)은 제거하고 /products/{no} 는 2분할 화면으로
리다이렉트한다. 편집기 조각을 두 곳에서 함께 쓰도록 _editor.html 로 분리했다.

검증: 유닛테스트 33개 통과(신규 3개 — 전체 조회의 페이지 순회·상한 처리·1회
종료). 상한 처리는 테스트가 잡아서 고쳤다(요청한 만큼 받았는지로 판정). 가짜
데이터로 렌더해 브라우저에서 실측: 왼쪽 360px·오른쪽 940px, 각 칸 독립 스크롤,
분할 영역이 화면 높이에 맞고, 가로 스크롤 없음, 정렬 오름/내림 동작 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:34:52 +09:00
parent 7a2e933c16
commit 119a128ee0
8 changed files with 828 additions and 351 deletions
+138 -100
View File
@@ -1,12 +1,16 @@
"""카페24 상품 화면 — 목록/검색 · 상세설명(HTML) 조회 · 편집 후 즉시 적용.
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다
(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도).
화면 구성
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 —
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다.
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
@@ -25,6 +29,7 @@ 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
@@ -38,15 +43,10 @@ logger = logging.getLogger("cafe24.products")
products_router = APIRouter()
# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다.
PAGE_SIZE = 50
def _page_param(raw: str | None) -> int:
try:
return max(1, int(raw or 1))
except ValueError:
return 1
def _checked(request: Request, name: str) -> bool:
"""체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다."""
return request.query_params.get(name) is not None
def _short_dt(value: Any) -> str:
@@ -58,75 +58,33 @@ def _short_dt(value: Any) -> str:
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
"""목록에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다)."""
"""왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정."""
normalized = products.normalize_product(raw)
return {
**normalized,
"product_no": normalized["product_no"],
"product_name": normalized["product_name"],
"display": normalized["display"],
"selling": normalized["selling"],
"updated_date": _short_dt(raw.get("updated_date")),
"price": str(raw.get("price") or ""),
}
@products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse:
"""상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리)."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
def _list_query(request: Request, *, selected: int | None = None) -> str:
"""현재 검색·필터를 유지한 목록 URL 쿼리스트링."""
params: list[tuple[str, str]] = []
keyword = (request.query_params.get("q") or "").strip()
page = _page_param(request.query_params.get("page"))
api = build_cafe24_api(st)
rows: list[dict[str, Any]] = []
total = 0
error = ""
try:
total = products.count_products(api.client, product_name=keyword)
raw_rows = products.list_products(
api.client,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
product_name=keyword,
)
rows = [_row_for_list(r) for r in raw_rows]
st.upsert_products([products.normalize_product(r) for r in raw_rows])
except Cafe24Error as exc:
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
last_page = max(1, -(-total // PAGE_SIZE)) if total else 1
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
"rows": rows,
"keyword": keyword,
"page": page,
"last_page": last_page,
"total": total,
"error": error,
}
)
return render_template(request, "cafe24/products.html", ctx)
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)
@products_router.get("/products/{product_no}", response_class=HTMLResponse)
def product_detail(request: Request, product_no: int) -> HTMLResponse:
"""상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
api = build_cafe24_api(st)
product: dict[str, Any] = {}
desc = None
@@ -140,32 +98,109 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse:
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,
# 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다.
# 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다.
"html_pc": store.decode_html_urls(desc.description) if desc else "",
"html_mobile": 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": f"카페24 상품 {product_no}",
"page_subtitle": product.get("product_name") or "상품 상세페이지",
"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,
# 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다.
# 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다.
"html_pc": store.decode_html_urls(desc.description) if desc else "",
"html_mobile": 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),
"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", ""),
}
)
return render_template(request, "cafe24/product.html", ctx)
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")
@@ -175,6 +210,7 @@ def product_apply(
html: str = Form(""),
base_fingerprint: str = Form(""),
memo: str = Form(""),
list_query: str = Form(""),
):
"""편집한 HTML 을 카페24에 즉시 적용한다.
@@ -193,12 +229,14 @@ def product_apply(
st, user = checked
actor = str(user.get("email") or "")
back = f"/cafe24/products/{product_no}"
# 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다.
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}"
submitted = store.encode_html_urls(html or "")
if not submitted.strip():
return RedirectResponse(
url=f"{back}?err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
status_code=303,
)
@@ -210,7 +248,7 @@ def product_apply(
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)
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
backup_id = st.add_revision(
product_no=product_no,
@@ -226,12 +264,12 @@ def product_apply(
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
)
return RedirectResponse(
url=f"{back}?err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
status_code=303,
)
if submitted == current.description:
return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
mobile_html = None if current.separated_mobile else submitted
@@ -246,7 +284,7 @@ def product_apply(
)
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
return RedirectResponse(
url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
status_code=303,
)
@@ -265,6 +303,6 @@ def product_apply(
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse(
url=f"{back}?msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
status_code=303,
)