feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널

증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야
반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT
뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만
기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값
백업이 생겼다.

- 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이
  유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막
  쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external).
  store.resolve_description / db.revision_digests(md5) / 배너 2종.
- 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회
  확인 결과는 감사로그에만 남긴다.
- 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products.
  last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date
  가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷.
- 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을
  한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/
  소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image
  + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목
  자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다.
- client.delete/timeout, products.upload_images·options·variants 래퍼.
- 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES,
  .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:07:42 +09:00
parent 8ea0db9245
commit 40766d805d
21 changed files with 2782 additions and 99 deletions
+396 -4
View File
@@ -11,7 +11,7 @@
flex-shrink: 0;
}
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
/* 분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
.erp-page {
@@ -24,8 +24,8 @@
}
/* ════════════════════════════════════════════════════════════
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
좌우 분할: 왼쪽 목록(좁게) | 가운데 상세페이지 편집(넓게) | 오른쪽 정보 패널
(.cf24-split-3 — 아래 3분할 절). 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
════════════════════════════════════════════════════════════ */
.cf24-split {
display: grid;
@@ -134,7 +134,7 @@
.cf24-col-name { word-break: break-word; }
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
전체 이름은 title 툴팁과 편집기 제목에서 확인한다. */
.cf24-col-name a {
color: inherit;
text-decoration: none;
@@ -803,3 +803,395 @@
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
}
/* ════════════════════════════════════════════════════════════
3분할 — 목록 | 편집기 | 상품 정보 패널
왼쪽 목록은 좁게, 가운데 편집기가 남는 폭을, 오른쪽 패널은 고정폭.
════════════════════════════════════════════════════════════ */
.cf24-split.cf24-split-3 {
/* 목록은 320~460px 사이에서 줄어들고, 편집기가 남는 폭을, 패널은 380px 고정.
1920px: 460 + 380 → 편집기 약 1000px. 1400px: 320 + 380 → 편집기 약 640px. */
grid-template-columns: minmax(320px, 460px) minmax(0, 1fr) 380px;
}
@media (max-width: 1360px) {
/* 폭이 모자라면 정보 패널을 편집기 아래로 내린다(목록은 그대로 왼쪽).
아래 칸은 높이를 제한해 편집기가 너무 눌리지 않게 한다(패널 안에서 스크롤). */
.cf24-split.cf24-split-3 {
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
grid-template-areas: "list editor" "list side";
grid-template-rows: minmax(0, 1fr) auto;
}
.cf24-split-3 > .cf24-pane-list { grid-area: list; }
.cf24-split-3 > .cf24-pane-editor { grid-area: editor; }
.cf24-split-3 > .cf24-pane-side { grid-area: side; max-height: 38vh; }
}
@media (max-width: 1100px) {
.cf24-split.cf24-split-3 {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: none;
}
.cf24-split-3 > .cf24-pane-list,
.cf24-split-3 > .cf24-pane-editor,
.cf24-split-3 > .cf24-pane-side { grid-area: auto; max-height: none; }
}
.cf24-pane-side {
display: flex;
flex-direction: column;
gap: var(--sp-12, 12px);
padding: var(--sp-12, 12px);
}
/* 읽기 지연 안내 배너 */
.cf24-flash-warn {
background: #fff8e6;
border: 1px solid #f0b429;
}
.cf24-btn-inline {
margin-left: var(--sp-8, 8px);
padding: 2px 8px;
font-size: 12px;
}
/* ── 정보 패널 공통 ── */
.cf24-side {
display: flex;
flex-direction: column;
gap: var(--sp-12, 12px);
}
.cf24-side-sec {
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
padding: var(--sp-10, 10px) var(--sp-12, 12px);
background: var(--color-canvas-white, #fff);
}
.cf24-side-title {
margin: 0 0 var(--sp-8, 8px);
font-size: var(--text-body, 14px);
font-weight: 600;
letter-spacing: -0.3px;
}
.cf24-side-details > summary.cf24-side-title {
cursor: pointer;
margin-bottom: 0;
}
.cf24-side-details[open] > summary.cf24-side-title {
margin-bottom: var(--sp-8, 8px);
}
.cf24-side-note {
margin: 0 0 var(--sp-8, 8px);
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-side-note-warn {
color: #9a6700;
}
.cf24-side-form {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
}
.cf24-side-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
min-width: 0;
}
.cf24-side-field input[type="text"],
.cf24-side-field select {
height: 32px;
box-sizing: border-box;
width: 100%;
padding: 0 var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: var(--text-body, 14px);
color: var(--color-rich-black, #0a0a0a);
background: var(--color-canvas-white, #fff);
}
.cf24-side-field input:focus,
.cf24-side-field select:focus {
outline: none;
border-color: var(--color-rich-black, #0a0a0a);
}
/* 값이 바뀐 입력칸은 테두리로 표시 — 무엇이 저장될지 보이게 */
.cf24-side input.is-dirty,
.cf24-side select.is-dirty {
border-color: #f0b429;
background: #fffbea;
}
.cf24-side-grid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-8, 8px);
}
.cf24-side-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-8, 8px);
}
.cf24-side-actions .cf24-muted {
min-width: 0;
flex: 1 1 auto;
}
.cf24-side-actions .is-ok { color: var(--color-success-green, #10c22b); }
.cf24-side-actions .is-err { color: var(--color-callout-red, #c22b10); }
/* ── 대표 이미지 ── */
.cf24-image-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
background: var(--color-ghost-gray, #f6f8fa);
margin-bottom: var(--sp-8, 8px);
overflow: hidden;
}
.cf24-image-main {
display: block;
max-width: 100%;
max-height: 260px;
object-fit: contain;
}
.cf24-image-empty {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
padding: var(--sp-16, 16px);
}
.cf24-image-thumbs {
display: flex;
gap: var(--sp-8, 8px);
margin-bottom: var(--sp-8, 8px);
}
.cf24-image-thumb {
margin: 0;
flex: 1 1 0;
min-width: 0;
text-align: center;
font-size: 11px;
color: var(--color-midtone-gray, #737373);
}
.cf24-image-thumb img {
display: block;
width: 100%;
height: 64px;
object-fit: contain;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
background: #fff;
}
.cf24-image-thumb img.is-hidden,
.cf24-side .is-hidden {
display: none;
}
.cf24-file-pick {
display: flex;
align-items: center;
gap: var(--sp-8, 8px);
cursor: pointer;
min-width: 0;
}
.cf24-file-pick input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.cf24-file-name {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* ── 옵션 / 품목 ── */
.cf24-options-body {
display: flex;
flex-direction: column;
gap: var(--sp-10, 10px);
}
.cf24-opt-group {
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
padding-top: var(--sp-8, 8px);
}
.cf24-opt-head {
display: flex;
gap: var(--sp-8, 8px);
align-items: flex-end;
margin-bottom: var(--sp-8, 8px);
}
.cf24-opt-head .cf24-side-field { flex: 1 1 auto; }
.cf24-opt-head .cf24-side-field-type { flex: 0 0 140px; }
.cf24-opt-value {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: var(--sp-8, 8px);
align-items: center;
padding: 4px 0;
}
.cf24-opt-thumb {
width: 44px;
height: 44px;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
background: var(--color-ghost-gray, #f6f8fa);
object-fit: cover;
display: block;
}
.cf24-opt-thumb-empty {
width: 44px;
height: 44px;
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--color-midtone-gray, #737373);
}
.cf24-opt-value input[type="text"] {
height: 30px;
box-sizing: border-box;
width: 100%;
padding: 0 var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: 13px;
}
.cf24-opt-value .cf24-file-pick .erp-btn {
padding: 3px 8px;
font-size: 12px;
}
/* 품목 표 — 좁은 칸에 맞춘 작은 글자 */
.cf24-variants {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.cf24-variants th,
.cf24-variants td {
padding: 4px 4px;
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
vertical-align: middle;
text-align: left;
}
.cf24-variants th {
font-weight: 500;
color: var(--color-midtone-gray, #737373);
white-space: nowrap;
}
.cf24-variants td.cf24-v-name {
word-break: break-word;
}
.cf24-variants input[type="text"] {
height: 28px;
box-sizing: border-box;
width: 100%;
padding: 0 6px;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
font-size: 12px;
}
.cf24-variants .cf24-v-amount input { text-align: right; }
/* "미진열"(3글자)이 한 줄에 들어가는 폭. 실측: 44px 에서는 두 줄로 꺾였다. */
.cf24-variants .cf24-v-flag { text-align: center; width: 54px; }
.cf24-variants .cf24-v-code { width: 92px; }
.cf24-variants .cf24-v-amount { width: 76px; }
/* 진열/판매 토글 — 저장 전까지는 화면만 바뀐다(배지와 같은 색 규칙) */
.cf24-v-toggle {
border: 1px solid transparent;
border-radius: 999px;
padding: 2px 6px;
font-size: 11px;
white-space: nowrap;
cursor: pointer;
background: var(--color-ghost-gray, #f2f2f2);
color: var(--color-rich-black, #0a0a0a);
}
.cf24-v-toggle.is-on {
background: var(--color-success-green, #10c22b);
color: #fff;
}
.cf24-v-toggle.is-dirty {
box-shadow: 0 0 0 2px #f0b429;
}
.cf24-variants-wrap {
max-height: 320px;
overflow: auto;
}
.cf24-opt-danger {
display: flex;
justify-content: flex-end;
padding-top: var(--sp-8, 8px);
}
.cf24-opt-danger .erp-btn {
color: var(--color-callout-red, #c22b10);
border-color: var(--color-callout-red, #c22b10);
}
.cf24-opt-create textarea {
width: 100%;
box-sizing: border-box;
min-height: 64px;
padding: var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: 13px;
resize: vertical;
}