feat(cafe24): 일괄수정 — 상세페이지 <style> 블록 통일

87개 상품의 상세설명 맨 위 <style> 을 정해진 내용으로 바꾸는 화면을 추가했다.

    <style>
    	div {
    		text-align: center;
    	}
    </style>

그냥 덮어쓰지 않고 검사 → 선택 → 적용 2단계로 만들었다. 상품 131번의 style 안에는
"비디오 태그 모바일 반응형 스타일" 같은 CSS 가 들어 있어서, 무엇이 지워지는지 보지
않고 87건을 일괄 실행하면 필요한 규칙이 조용히 사라진다. 검사 결과 표에 지금 들어
있는 CSS 를 그대로 보여주고, 변경이 필요한 상품만 자동 선택한다(이미 같은 내용이면
「이미 동일」로 제외).

맨 앞 <style> 블록 하나만 바꾼다. 아래쪽에 <style> 이 더 있으면 건드리지 않고
「블록 2개 · 주의」로 표시해 사람이 판단하게 한다 — 일괄 작업이 남의 CSS 를 조용히
지우는 것이 가장 위험하다. 블록이 없는 상품은 맨 앞에 넣는다.

상품 1건당 1요청으로 쪼갰다. 87건을 한 요청으로 묶으면 1분 가까이 걸려 프록시
타임아웃에 걸리고, 동시에 던지면 카페24 호출 제한(429)에 걸린다. 브라우저가 순차
호출하며 진행률을 보여주고, 한 건 실패가 나머지를 막지 않으며 어디까지 됐는지
화면에 남는다.

적용 순서는 단건 편집과 같은 원칙을 지킨다: 카페24 현재값 재조회 → BACKUP 버전 →
교체 → PUT → MANUAL 버전 + 감사로그(action=bulk_style). 검사 때 읽은 값을 재사용하지
않고 쓰기 직전에 다시 읽는다. PC/모바일 분리 상품은 모바일도 함께 바꾼다.

검증: 유닛테스트 51개 통과(신규 6개 — 앞 블록만 교체하고 뒤 블록 보존, 없을 때 삽입,
멱등, 포맷 후 탭 유지, 여러 줄 원문 정확히 절단). 실제 데이터로 미리보기 로직 확인:
비디오 CSS 가 "지워질 내용"에 잡히고, 이미 동일한 상품은 will_change=False,
style 없는 상품은 삽입 대상으로 판정. 라우트 13개 등록, 템플릿 렌더 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:12:24 +09:00
parent 25ed369583
commit 3522fc2119
7 changed files with 502 additions and 4 deletions
+4 -1
View File
@@ -9,7 +9,8 @@
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에 라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회 routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
routes_bulk 일괄수정 (검사 → 선택 적용)
routes_system 연결(OAuth)·상태·API 로그·작업 로그 routes_system 연결(OAuth)·상태·API 로그·작업 로그
(Phase 5) routes_schedules (Phase 5) routes_schedules
""" """
@@ -22,6 +23,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from .common import base_ctx, guard from .common import base_ctx, guard
from .routes_bulk import bulk_router
from .routes_products import products_router from .routes_products import products_router
from .routes_system import system_router from .routes_system import system_router
@@ -30,6 +32,7 @@ logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router) router.include_router(products_router)
router.include_router(bulk_router)
router.include_router(system_router) router.include_router(system_router)
+194
View File
@@ -0,0 +1,194 @@
"""카페24 일괄수정 — 상세페이지 맨 위 `<style>` 블록 통일.
**미리보기 먼저, 적용은 확인 후.** 87개 상품을 한 번에 쓰는 작업이라, 무엇이
지워지는지 보지 않고 실행하면 남의 CSS(예: 비디오 반응형 스타일)가 조용히 사라진다.
그래서 두 단계로 나눈다.
1) 검사 GET /bulk/scan/{no} → 그 상품의 현재 <style> 내용과 교체 후 모양
2) 적용 POST /bulk/apply/{no} → 그 상품 하나만 실제로 반영
상품 1건당 1요청으로 쪼갠 이유:
- 87건을 한 요청으로 처리하면 1분 가까이 걸려 프록시 타임아웃에 걸린다.
- 브라우저가 순차 호출하며 진행률을 보여줄 수 있고, 한 건 실패가 나머지를 막지
않으며, 어디까지 됐는지 화면에 남는다.
적용 순서는 단건 편집(`routes_products.product_apply`)과 같은 원칙을 지킨다.
카페24 현재값 재조회 → BACKUP 버전 → 교체 → PUT → MANUAL 버전 + 감사로그
지문 대조는 하지 않는다 — 쓰기 직전에 방금 읽은 값을 그대로 쓰기 때문이다.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
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.bulk")
bulk_router = APIRouter()
# 통일할 <style> 블록. 탭 들여쓰기까지 사용자가 준 모양 그대로 쓴다
# (format_html 은 <style> 안쪽을 건드리지 않으므로 그대로 저장된다).
TARGET_STYLE_BLOCK = "<style>\n\tdiv {\n\t\ttext-align: center;\n\t}\n</style>"
def _preview(html: str) -> dict[str, Any]:
"""현재 <style> 상태와 교체 후 모양을 요약한다."""
blocks = store.find_style_blocks(html or "")
current = blocks[0] if blocks else ""
after = store.replace_first_style_block(html or "", TARGET_STYLE_BLOCK)
return {
"style_count": len(blocks),
"current_style": current,
"already_target": current.strip() == TARGET_STYLE_BLOCK.strip(),
"will_change": after != (html or ""),
}
@bulk_router.get("/bulk", response_class=HTMLResponse)
def bulk_page(request: Request) -> HTMLResponse:
"""일괄수정 화면. 상품 목록만 서버에서 그리고, 검사는 브라우저가 순차 호출한다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
api = build_cafe24_api(st)
rows: list[dict[str, Any]] = []
error = ""
try:
raw_rows, _truncated = products.list_all_products(api.client)
st.upsert_products([products.normalize_product(r) for r in raw_rows])
rows = [
{
"product_no": normalized["product_no"],
"product_name": normalized["product_name"],
"display": normalized["display"],
"selling": normalized["selling"],
}
for normalized in (products.normalize_product(r) for r in raw_rows)
]
except Cafe24Error as exc:
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
ctx = base_ctx(request, user, active_tab="bulk")
ctx.update(
{
"page_title": "카페24 — 일괄수정",
"page_subtitle": "상세페이지 <style> 블록 통일",
"rows": rows,
"total": len(rows),
"target_style": TARGET_STYLE_BLOCK,
"error": error,
}
)
return render_template(request, "cafe24/bulk.html", ctx)
@bulk_router.get("/bulk/scan/{product_no}")
def bulk_scan(request: Request, product_no: int) -> dict[str, Any]:
"""상품 1건의 현재 <style> 상태(읽기 전용)."""
st, _user = require_store(request)
api = build_cafe24_api(st)
try:
desc = products.fetch_descriptions(api.client, product_no)
except Cafe24Error as exc:
raise HTTPException(status_code=502, detail=str(exc)) from None
pc = _preview(desc.description)
mobile = _preview(desc.mobile_description)
return {
"product_no": product_no,
"product_name": desc.product_name,
"separated_mobile": desc.separated_mobile,
"pc": pc,
# 분리 상품만 모바일을 따로 본다(미분리는 PC 값을 그대로 쓴다).
"mobile": mobile if desc.separated_mobile else None,
"will_change": pc["will_change"] or (desc.separated_mobile and mobile["will_change"]),
}
@bulk_router.post("/bulk/apply/{product_no}")
def bulk_apply(request: Request, product_no: int) -> dict[str, Any]:
"""상품 1건의 맨 위 <style> 을 통일된 블록으로 교체한다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
api = build_cafe24_api(st)
# 1) 현재값을 다시 읽는다 — 로컬 값이나 방금 검사한 값을 믿지 않는다.
try:
current = products.fetch_descriptions(api.client, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="bulk_style", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}",
)
raise HTTPException(status_code=502, detail=str(exc)) from None
# 2) BACKUP — 유일한 복구 수단
backup_id = st.add_revision(
product_no=product_no,
html_content=current.description,
revision_type=store.REVISION_BACKUP,
memo="일괄 style 교체 직전 자동 백업",
created_by=actor,
)
new_pc = store.format_html(
store.replace_first_style_block(current.description, TARGET_STYLE_BLOCK)
)
if current.separated_mobile:
new_mobile: str | None = store.format_html(
store.replace_first_style_block(current.mobile_description, TARGET_STYLE_BLOCK)
)
if new_mobile == current.mobile_description:
new_mobile = None
else:
new_mobile = new_pc
if new_pc == current.description and (
new_mobile is None or new_mobile == current.mobile_description
):
return {"product_no": product_no, "result": "SKIPPED", "detail": "이미 같은 내용", "backup_id": backup_id}
try:
products.update_descriptions(
api.client, product_no, description=new_pc, mobile_description=new_mobile
)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="bulk_style", product_no=product_no,
revision_id=backup_id, result="FAIL", detail=str(exc),
)
logger.warning("카페24 상품 %s 일괄 style 적용 실패: %s", product_no, exc)
raise HTTPException(status_code=502, detail=f"{exc} (직전 내용은 버전 {backup_id})") from None
revision_id = st.add_revision(
product_no=product_no,
html_content=new_pc,
revision_type=store.REVISION_MANUAL,
memo="일괄 style 교체",
created_by=actor,
)
st.log_audit(
actor=actor, action="bulk_style", product_no=product_no,
revision_id=revision_id, result="SUCCESS",
detail=f"style 통일 (백업 {backup_id}"
+ (", 모바일 동시 반영)" if new_mobile is not None else ")"),
)
return {
"product_no": product_no,
"result": "SUCCESS",
"backup_id": backup_id,
"revision_id": revision_id,
}
+28
View File
@@ -368,6 +368,34 @@ def _collapse_short_blocks(lines: list[str]) -> list[str]:
return out return out
# ════════════════════════════════════════════════════════════
# <style> 블록 일괄 교체
#
# 상세페이지 맨 위의 <style> 을 정해진 내용으로 통일할 때 쓴다.
# **맨 앞 블록 하나만** 건드린다. 아래쪽에 다른 <style> 이 더 있으면 그건 그대로
# 두고, 화면에서 "블록 2개" 로 알려 사람이 판단하게 한다 — 일괄 작업이 남의 CSS 를
# 조용히 지우는 것이 가장 위험하다.
# ════════════════════════════════════════════════════════════
_STYLE_BLOCK_RE = re.compile(r"<style\b[^>]*>.*?</style>", re.IGNORECASE | re.DOTALL)
def find_style_blocks(html: str) -> list[str]:
"""상세설명 안의 <style>…</style> 블록 전체(원문 그대로)."""
return _STYLE_BLOCK_RE.findall(html or "")
def replace_first_style_block(html: str, new_block: str) -> str:
"""맨 앞 <style> 블록을 new_block 으로 교체한다.
블록이 하나도 없으면 맨 앞에 넣는다. 두 번째 이후 블록은 건드리지 않는다.
"""
source = html or ""
match = _STYLE_BLOCK_RE.search(source)
if match is None:
return new_block + ("\n" + source.lstrip("\n") if source.strip() else "")
return source[: match.start()] + new_block + source[match.end() :]
def fingerprint(html: str) -> str: def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다. """편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
@@ -1,7 +1,9 @@
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #} {# 카페24 모듈 공용 상단 탭. active_tab: products | bulk | schedules | system #}
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"> <div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" <a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/">상품관리</a> href="/cafe24/">상품관리</a>
<a class="erp-btn {% if active_tab=='bulk' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/bulk">일괄수정</a>
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" <a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/schedules">예약관리</a> href="/cafe24/schedules">예약관리</a>
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" <a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
@@ -0,0 +1,192 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>상세페이지 &lt;style&gt; 블록 통일</h3>
<span class="cf24-muted">전체 {{ total }}건</span>
</div>
<p class="cf24-note">
각 상품 상세설명의 <strong>맨 위 <code>&lt;style&gt;</code> 블록 하나</strong>를 아래 내용으로 바꿉니다.
아래쪽에 <code>&lt;style&gt;</code> 이 더 있으면 그건 건드리지 않고 「블록 2개」로 표시합니다.<br />
<strong class="cf24-warn">지금 들어 있는 CSS 는 사라집니다.</strong>
비디오 반응형처럼 필요한 규칙이 있을 수 있으니, <strong>먼저 검사</strong>해서 무엇이 지워지는지
확인하고 상품을 골라 적용하세요. 적용 직전 내용은 상품별 <code>BACKUP</code> 버전으로 보관됩니다.
PC/모바일 분리 상품은 모바일도 함께 바꿉니다.
</p>
<label class="cf24-label" for="cf24-target">교체할 내용</label>
<textarea id="cf24-target" class="cf24-html" rows="6" readonly>{{ target_style }}</textarea>
<div class="cf24-toolbar" style="margin-top:12px;">
<button class="erp-btn erp-btn-primary" type="button" id="cf24-scan">1. 전체 검사</button>
<button class="erp-btn erp-btn-outline" type="button" id="cf24-apply" disabled>2. 선택한 상품에 적용</button>
<span class="cf24-muted" id="cf24-progress">검사를 먼저 실행하세요.</span>
</div>
</div>
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>검사 결과</h3>
<span class="cf24-muted">변경이 필요한 상품만 자동 선택됩니다</span>
</div>
<div class="cf24-scroll">
<table class="erp-table cf24-compact" id="cf24-bulk-table">
<thead>
<tr>
<th style="width:34px;"><input type="checkbox" id="cf24-check-all" /></th>
<th style="width:60px;">번호</th>
<th>상품명</th>
<th style="width:70px;">style</th>
<th>현재 내용 (지워질 부분)</th>
<th style="width:90px;">상태</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr data-no="{{ r.product_no }}">
<td><input type="checkbox" class="cf24-pick" disabled /></td>
<td class="cf24-nowrap">{{ r.product_no }}</td>
<td>{{ r.product_name }}</td>
<td class="cf24-nowrap cf24-cell-count"></td>
<td class="cf24-cell-current"><span class="cf24-muted">검사 전</span></td>
<td class="cf24-nowrap cf24-cell-state"><span class="cf24-muted">대기</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll("#cf24-bulk-table tbody tr"));
var scanBtn = document.getElementById("cf24-scan");
var applyBtn = document.getElementById("cf24-apply");
var progress = document.getElementById("cf24-progress");
var checkAll = document.getElementById("cf24-check-all");
function cell(tr, name) { return tr.querySelector(".cf24-cell-" + name); }
function setState(tr, text, cls) {
cell(tr, "state").innerHTML = '<span class="' + (cls || "cf24-muted") + '">' + text + "</span>";
}
// 한 건씩 순차 호출한다 — 87건을 한 요청으로 묶으면 프록시 타임아웃에 걸리고,
// 동시에 던지면 카페24 호출 제한(429)에 걸린다.
function walk(list, step, done) {
var i = 0;
(function next() {
if (i >= list.length) { done(); return; }
var tr = list[i++];
step(tr, function () {
progress.textContent = i + " / " + list.length;
next();
});
})();
}
scanBtn.addEventListener("click", function () {
scanBtn.disabled = true;
applyBtn.disabled = true;
progress.textContent = "검사 중… 0 / " + rows.length;
walk(rows, function (tr, next) {
setState(tr, "검사 중…");
fetch("/cafe24/bulk/scan/" + tr.dataset.no, { credentials: "same-origin" })
.then(function (res) { return res.ok ? res.json() : Promise.reject(res); })
.then(function (data) {
var pick = tr.querySelector(".cf24-pick");
cell(tr, "count").textContent = data.pc.style_count + "개";
if (data.pc.style_count > 1) {
cell(tr, "count").innerHTML += ' <span class="cf24-warn">주의</span>';
}
var current = data.pc.current_style || "";
cell(tr, "current").innerHTML = current
? "<code>" + current.replace(/[&<>]/g, function (c) {
return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;";
}).slice(0, 400) + "</code>"
: '<span class="cf24-muted">없음 (새로 넣습니다)</span>';
pick.disabled = !data.will_change;
pick.checked = data.will_change;
if (data.pc.already_target && !data.will_change) setState(tr, "이미 동일", "cf24-muted");
else setState(tr, "변경 필요", "cf24-warn");
if (data.separated_mobile) {
cell(tr, "count").innerHTML += ' <span class="cf24-muted">/ 모바일분리</span>';
}
next();
})
.catch(function (res) {
setState(tr, "검사 실패", "cf24-err");
if (res && res.status === 401) window.location.href = "/login";
next();
});
}, function () {
scanBtn.disabled = false;
applyBtn.disabled = false;
var picked = rows.filter(function (tr) { return tr.querySelector(".cf24-pick").checked; }).length;
progress.textContent = "검사 완료 — 변경 필요 " + picked + "건 선택됨";
});
});
checkAll.addEventListener("change", function () {
rows.forEach(function (tr) {
var pick = tr.querySelector(".cf24-pick");
if (!pick.disabled) pick.checked = checkAll.checked;
});
});
applyBtn.addEventListener("click", function () {
var picked = rows.filter(function (tr) { return tr.querySelector(".cf24-pick").checked; });
if (!picked.length) { window.alert("선택된 상품이 없습니다."); return; }
if (!window.confirm(picked.length + "개 상품의 상세페이지에 바로 반영됩니다.\n\n" +
"지금 들어 있는 <style> 내용은 사라집니다(상품별 BACKUP 버전으로 보관).\n계속할까요?")) return;
scanBtn.disabled = true;
applyBtn.disabled = true;
progress.textContent = "적용 중… 0 / " + picked.length;
var ok = 0, fail = 0, skip = 0;
walk(picked, function (tr, next) {
setState(tr, "적용 중…");
fetch("/cafe24/bulk/apply/" + tr.dataset.no, { method: "POST", credentials: "same-origin" })
.then(function (res) {
return res.json().then(function (body) {
if (!res.ok) return Promise.reject(body);
return body;
});
})
.then(function (data) {
if (data.result === "SKIPPED") { skip++; setState(tr, "변경 없음", "cf24-muted"); }
else { ok++; setState(tr, "완료", "cf24-badge-ok"); tr.querySelector(".cf24-pick").checked = false; }
next();
})
.catch(function (body) {
fail++;
setState(tr, "실패", "cf24-err");
if (body && body.detail) cell(tr, "current").innerHTML =
'<span class="cf24-err">' + String(body.detail).slice(0, 200) + "</span>";
next();
});
}, function () {
scanBtn.disabled = false;
applyBtn.disabled = false;
progress.textContent = "적용 완료 — 성공 " + ok + " / 변경없음 " + skip + " / 실패 " + fail;
});
});
})();
</script>
{% endblock %}
+52
View File
@@ -539,6 +539,58 @@ def test_format_then_encode_roundtrip():
assert saved == raw assert saved == raw
# ════════════════════════════════════════════════════════════
# <style> 블록 일괄 교체
# ════════════════════════════════════════════════════════════
_NEW_STYLE = "<style>\n\tdiv {\n\t\ttext-align: center;\n\t}\n</style>"
def test_find_style_blocks():
html = "<style>a{}</style><div>x</div><style type=\"text/css\">b{}</style>"
blocks = store.find_style_blocks(html)
assert len(blocks) == 2
assert blocks[0] == "<style>a{}</style>"
def test_replace_first_style_block_only():
"""두 번째 이후 <style> 은 건드리지 않는다(남의 CSS 를 조용히 지우지 않게)."""
html = "<style>OLD</style><div>x</div><style>KEEP</style>"
out = store.replace_first_style_block(html, _NEW_STYLE)
assert "OLD" not in out
assert "<style>KEEP</style>" in out
assert out.startswith(_NEW_STYLE)
assert "<div>x</div>" in out
def test_replace_style_inserts_when_missing():
out = store.replace_first_style_block("<div>x</div>", _NEW_STYLE)
assert out.startswith(_NEW_STYLE)
assert "<div>x</div>" in out
def test_replace_style_is_idempotent():
once = store.replace_first_style_block("<style>OLD</style><div>x</div>", _NEW_STYLE)
assert store.replace_first_style_block(once, _NEW_STYLE) == once
def test_replace_style_survives_formatting():
"""정리(포맷)를 거쳐도 <style> 안 탭·줄바꿈이 그대로 남는다."""
formatted = store.format_html(
store.replace_first_style_block("<style>OLD</style><div>x</div>", _NEW_STYLE)
)
assert "\tdiv {" in formatted
assert "\t\ttext-align: center;" in formatted
assert len(store.find_style_blocks(formatted)) == 1
def test_replace_style_keeps_multiline_original_shape():
"""원문이 여러 줄이어도 앞 블록만 정확히 잘라낸다."""
html = '<style>\n/* 비디오 반응형 */\n.video{max-width:100%}\n</style>\n<img src="a.gif">'
out = store.replace_first_style_block(html, _NEW_STYLE)
assert "비디오" not in out
assert '<img src="a.gif">' in out
def test_fingerprint_detects_change(): def test_fingerprint_detects_change():
a = store.fingerprint("<p>A</p>") a = store.fingerprint("<p>A</p>")
assert a == store.fingerprint("<p>A</p>") assert a == store.fingerprint("<p>A</p>")
+29 -2
View File
@@ -26,13 +26,15 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
app/modules/cafe24/ ← 상품관리 모듈 app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) ├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
├─ routes_bulk.py 일괄수정 (검사 → 선택 적용)
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트 ├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html(2분할) · └─ templates/cafe24/ _nav.html · products.html(2분할) ·
_editor.html(오른쪽 조각) · schedules.html · system.html _editor.html(오른쪽 조각) · bulk.html ·
schedules.html · system.html
``` ```
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시 **규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
@@ -52,6 +54,9 @@ app/modules/cafe24/ ← 상품관리 모듈
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` | | `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` | | `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` | | `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
| `GET /cafe24/bulk` | 일괄수정 화면 (`<style>` 통일) | `cafe24` |
| `GET /cafe24/bulk/scan/{product_no}` | 상품 1건 검사 (JSON, 읽기 전용) | `cafe24` |
| `POST /cafe24/bulk/apply/{product_no}` | 상품 1건 적용 (JSON) | `cafe24` |
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` | | `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` | | `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** | | `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
@@ -135,6 +140,28 @@ app/modules/cafe24/ ← 상품관리 모듈
--- ---
### 2-3. 일괄수정 (`<style>` 블록 통일)
87개 상품에 한 번에 쓰는 작업이라 **검사 → 선택 → 적용** 2단계로 나눈다. 무엇이
지워지는지 보지 않고 실행하면 남의 CSS(예: 비디오 반응형 스타일)가 조용히 사라진다.
- **맨 앞 `<style>` 블록 하나만** 바꾼다(`store.replace_first_style_block`). 아래쪽에
`<style>` 이 더 있으면 건드리지 않고 화면에 「블록 2개 · 주의」로 표시한다.
블록이 없으면 맨 앞에 넣는다.
- 검사 결과 표에 **지금 들어 있는 CSS 를 그대로 보여준다** — 그게 지워질 내용이다.
변경이 필요한 상품만 자동 선택되고, 이미 같은 내용이면 「이미 동일」로 제외된다.
- 적용은 상품 1건당 1요청이다. 87건을 한 요청으로 묶으면 1분 가까이 걸려 프록시
타임아웃에 걸리고, 동시에 던지면 호출 제한(429)에 걸린다. 브라우저가 순차 호출하며
진행률을 보여주고, 한 건 실패가 나머지를 막지 않는다.
- 적용 순서는 단건 편집과 같다: **현재값 재조회 → BACKUP → 교체 → PUT → MANUAL +
감사로그**(`action=bulk_style`). 지문 대조는 없다 — 방금 읽은 값을 바로 쓰기 때문.
- PC/모바일 분리 상품은 모바일도 함께 바꾼다.
교체할 내용은 `routes_bulk.TARGET_STYLE_BLOCK` 에 있다. `<style>` 안쪽은
`format_html` 이 건드리지 않으므로 탭 들여쓰기까지 그대로 저장된다.
---
## 3. OAuth 흐름 ## 3. OAuth 흐름
``` ```
@@ -301,7 +328,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 | | 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 | | 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
| 6 | 자동 종료/복원 · 롤백 | 예정 | | 6 | 자동 종료/복원 · 롤백 | 예정 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | 예정 | | 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ◐ `<style>` 통일 일괄수정 완료. 일괄 예약 예정 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이 Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다. `COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.