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:
+396
-4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user