3537e058b0
제목 옆 연필 버튼 → 입력칸 → 저장. 이름만 바꾸려고 카페24 관리자에 들어갈
필요가 없어진다.
- POST /cafe24/products/{no}/name (JSON) 추가. 쓰기 전에 카페24 현재값을 읽어
이전 이름을 감사로그(rename_product)에 남긴다 - 되돌릴 revision 이 없으므로
로그가 유일한 복구 단서다. 값이 같으면 호출하지 않는다.
- build_update_payload/update_product 에 product_name 추가(부분 수정이라
상세설명·진열·판매는 그대로).
- 빈 값과 250자 초과는 서버에서 400. 화면도 maxlength 로 막는다.
- 평소에는 읽기 전용 제목이고 연필을 눌러야 입력칸이 된다 - 클릭 한 번으로
실수로 고쳐지지 않게. Enter 저장, Esc 취소.
- 화면은 요청값이 아니라 카페24가 확인해 준 이름으로 다시 그리고, 왼쪽 목록의
이름·정렬키(data-name)도 함께 갱신한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
20 KiB
Python
472 lines
20 KiB
Python
"""카페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))
|
|
|
|
|
|
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
|
NAME_MAX = 250
|
|
|
|
|
|
@products_router.post("/products/{product_no}/name")
|
|
def product_rename(
|
|
request: Request,
|
|
product_no: int,
|
|
payload: dict[str, Any] = Body(default_factory=dict),
|
|
) -> dict[str, Any]:
|
|
"""상품명만 바꾼다 — 편집기 제목 옆 연필 버튼용(JSON API).
|
|
|
|
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
|
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
|
(되돌리려면 로그를 보고 다시 바꾼다).
|
|
"""
|
|
st, user = require_store(request)
|
|
actor = str(user.get("email") or "")
|
|
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
|
|
if len(name) > NAME_MAX:
|
|
raise HTTPException(status_code=400, detail=f"상품명은 {NAME_MAX}자를 넘을 수 없습니다.")
|
|
|
|
api = build_cafe24_api(st)
|
|
try:
|
|
current = products.get_product(api.client, product_no)
|
|
except Cafe24Error as exc:
|
|
st.log_audit(
|
|
actor=actor, action="rename_product", product_no=product_no,
|
|
result="FAIL", detail=f"현재값 조회 실패: {exc}",
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"카페24 현재값을 읽지 못했습니다: {exc}") from exc
|
|
|
|
before = str(current.get("product_name") or "")
|
|
if before == name:
|
|
return {"ok": True, "product_name": before, "changed": False}
|
|
|
|
try:
|
|
updated = products.update_product(api.client, product_no, product_name=name)
|
|
except Cafe24Error as exc:
|
|
st.log_audit(
|
|
actor=actor, action="rename_product", product_no=product_no,
|
|
result="FAIL", detail=f"'{before}' → '{name}' 실패: {exc}",
|
|
)
|
|
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])
|
|
after = str(info.get("product_name") or name)
|
|
st.log_audit(
|
|
actor=actor, action="rename_product", product_no=product_no,
|
|
result="SUCCESS", detail=f"'{before}' → '{after}'",
|
|
)
|
|
logger.info("카페24 상품 %s 이름 변경 (%s)", product_no, actor)
|
|
return {"ok": True, "product_name": after, "changed": True}
|
|
|
|
|
|
@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,
|
|
)
|