11 Commits

Author SHA1 Message Date
king a1e222acb9 test(cafe24): scope 검증을 DEFAULT_SCOPES 기준으로
디자인 scope 를 추가하면서 요청 scope 문자열을 하드코딩한 테스트가 깨졌다.
DEFAULT_SCOPES 를 기준으로 비교하도록 바꿔, 앞으로 scope 가 늘어도 authorize 와
클라이언트가 같은 목록을 쓰는지만 확인하게 했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:54:55 +09:00
king 87fca22ea0 feat(cafe24): 디자인 스코프 추가 (mall.read_design/write_design)
상단 공통 홍보를 스킨 쪽에서 처리할 수 있는지 실물로 확인하려면 토큰에 디자인
권한이 있어야 한다. 개발자센터 앱에는 이미 추가됐고, 우리가 요청하는 scope 에도
넣는다. 재인증을 해야 실제 토큰에 반영된다.

확인해 둔 사실을 config.py 주석으로 남겼다. 카페24 Admin API 에는 스킨 HTML
파일(detail.html)을 직접 읽거나 쓰는 엔드포인트가 없다. 조회 가능한 것은 테마
목록과 테마 페이지뿐이며, 스크립트 태그 주입은 디자인이 아니라 mall.write_store
권한이고 인라인 코드가 아닌 외부 HTTPS URL 만 받는다.

요청 scope 를 DEFAULT_SCOPES 한 곳으로 모아, 앞으로 늘어날 때 authorize 와
클라이언트가 어긋나지 않게 했다.

검증: 유닛테스트 49개 통과. scope_param 출력 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:54:15 +09:00
king 08ebe7b53d feat(cafe24): 상단 공통 홍보 숨기기 체크박스 (PC·모바일 동시)
요청은 스킨 detail.html 의 `const numbers = [...]` 를 고치는 것이었지만, 카페24
Admin API 로는 스킨 HTML 파일을 읽거나 쓸 수 없다. 확인 결과 테마는 조회만
가능하고(GET /admin/themes) 스킨 파일 엔드포인트가 없다. 쓸 수 있는 것은 테마
페이지와 스크립트 태그뿐이다.

그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다. 상세설명은 이미 우리가 쓸 수
있는 영역이고, 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
새 권한이나 재인증도 필요하지 않다.

    <style id="cf24-hide-common-promo">.edb-img-tag-w{display:none !important}</style>

id 로 우리 블록만 찾으므로 사람이 쓴 <style> 은 건드리지 않는다. 넣기/빼기는
멱등이고 소스 정리(format_html)를 거쳐도 상태가 유지된다.

PC·모바일 모두 반영한다. 미분리 상품은 같은 HTML 이 양쪽에 들어가고, 분리 상품은
모바일 본문을 건드리지 않되 이 블록만 모바일에도 맞춘다 — 양쪽에 걸지 않으면 한쪽에
홍보가 그대로 남는다. PC/모바일 상태가 다르면 화면에 불일치를 알린다.

"변경 없음" 판정에 모바일 변경도 포함시켰다. PC 는 그대로인데 모바일 숨김만
바뀌는 경우가 있어서, 예전 조건이면 아무 일도 하지 않고 끝났다.

스킨의 numbers 목록과는 독립이며 충돌하지 않는다(스킨은 요소 제거, 이쪽은 CSS 숨김).
이미 목록에 있는 상품은 그대로 두면 된다.

검증: 유닛테스트 49개 통과(신규 4개 — 추가/제거 왕복, 멱등, 포맷 통과 후 인식,
사람이 쓴 style 보존).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:44:43 +09:00
king b60690d3e1 fix(cafe24): 편집기 커서 위치가 어긋나던 문제 — 스크롤 동기화로 전환
증상: 줄 끝에 커서를 두고 엔터를 치면 커서보다 앞쪽에서 줄이 나뉘었다.

원인은 두 층의 크기를 맞추는 방식이었다. 색칠된 <pre> 가 상자 크기를 정하고
textarea 가 그 위를 덮게 했는데, flex 자식에서 `width: max-content` 가 기대대로
적용되지 않아 화면보다 긴 줄이 있으면 textarea 만 내부 스크롤이 가능한 상태가 됐다
(실측 scrollWidth 706 vs clientWidth 686). 캐럿을 따라 textarea 가 내부적으로
스크롤되면 색칠 층은 제자리에 남아, 보이는 글자와 실제 문자 오프셋이 어긋난다.
그래서 커서를 둔 곳과 다른 위치에 개행이 들어갔다.

크기를 맞추려는 시도를 버리고 스크롤 주체를 textarea 로 두고 색칠 층과 줄 번호를
transform 으로 같은 양만큼 이동시킨다. 크기 계산이 아예 없으므로 어긋날 여지가
없다. 줄 번호 칸도 코드 영역 왼쪽의 독립 박스로 바꿔(sticky 제거) 가로 스크롤과
무관해졌다.

검증(브라우저 실측): 가로 스크롤 33px·세로 200px 에서 색칠 층과 줄 번호가 정확히
같은 양만큼 이동, 스크롤 0 에서 두 층의 텍스트 원점 일치, 줄 번호 개수가 줄 수와
일치(10/10, 70/70). 유닛테스트 45개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:41:09 +09:00
king 5284fb1c6e fix(cafe24): 소스 들여쓰기 정상화 + 줄 번호·가로 스크롤 편집기
1) 들여쓰기가 안 되던 원인

인라인 요소가 여러 줄에 걸쳐 있으면 그 구간을 하나의 텍스트 덩어리로 다뤄
첫 줄에만 들여쓰기를 붙이고 있었다. 그래서 <img> 가 한 줄에 하나씩 적힌
상세페이지에서 두 번째 <img> 부터 1열에 붙어 나왔다.

원문 줄바꿈을 살리면서 각 줄을 현재 깊이로 들여쓰도록 고쳤다. 줄 앞 공백은
렌더링에 영향이 없으므로 안전하다. 상세페이지는 <img> 를 한 줄에 하나씩 적어두는
경우가 많고 그 모양이 저자의 의도라, 한 줄로 합치는 것보다 살리는 쪽이 읽기 좋다.

주석도 줄을 강제로 나누지 않게 바꿨다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로
뒤 요소를 설명하는 주석이 많아서, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.

빈 줄은 구획 표시로 한 줄까지 유지한다. 이 과정에서 멱등성이 다시 깨지는 것을
테스트가 잡았다 — 텍스트 끝에 남은 "\n  " 조각이 매번 빈 줄로 바뀌고 있었고,
양 끝 공백을 함께 제거하도록 고쳤다.

2) 줄 번호와 가로 스크롤

전문 편집기처럼 줄 번호 칸을 넣었다. 줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어
번호가 어긋나므로, 줄바꿈을 끄고(white-space:pre + wrap=off) 가로로 스크롤한다.
번호 칸은 position:sticky 라 가로로 스크롤해도 왼쪽에 남는다.

크기 계산도 단순해졌다. <pre> 가 흐름에 남아 상자 크기를 정하고 textarea 가
inset:0 으로 그 위를 덮는다 — JS 로 높이를 맞추지 않으니 어긋날 여지가 없다.
색상 계열은 그대로 뒀다(태그 초록·속성 갈색·값 남색·주석 회색).

검증: 유닛테스트 45개 통과(신규 4개 — 모든 줄 들여쓰기, 주석과 요소 붙임,
빈 줄 1개 유지, 실제 상세페이지 모양 멱등). 브라우저 실측: 줄 번호 개수가 줄 수와
일치(18/18, 19/19), 번호와 코드의 세로 위치 일치, 긴 줄에서 편집기 안에서만 가로
스크롤(1920 > 716)되고 페이지는 가로 스크롤 없음, 400px 스크롤 후에도 번호 칸 고정,
textarea 내부 스크롤 0(두 층 정렬 유지).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:03:54 +09:00
king 1d3dac3bec feat(cafe24): 문법 강조 편집기 + 소스 자동 정리, 목록 550px
1) 검색 입력란이 거대했던 버그

.cf24-filters 가 세로 flex 인데 .cf24-search 에 flex:0 1 320px 을 줬다. 세로
방향에서는 flex-basis 가 '높이'로 적용돼 입력란이 320px 짜리 상자가 됐다.
height:32px 로 한 줄에 고정했고, 그만큼 목록이 더 보인다.

2) 목록 550px

요청대로 왼쪽을 550px 로 넓혔다. 남은 폭(약 290px)이 상품명 몫이라 대부분 한 줄에
들어가고, 수정일도 월-일 시:분까지 보여준다. 컬럼은 번호·상품명·진열·판매·수정 5개
그대로다.

3) 문법 강조 편집기

색칠된 <pre> 위에 투명한 <textarea> 를 겹치는 방식으로 직접 구현했다. 외부
라이브러리를 쓰지 않는 이유는 자체 호스팅 원칙이다(CDN 의존 금지). 태그·속성이름·
속성값·주석·기호를 색으로 구분하고 Tab 은 들여쓰기로 쓴다.

두 층의 글자가 어긋나지 않으려면 폰트·줄높이·padding·줄바꿈 규칙이 완전히 같아야
한다. 특히 높이는 <pre> 의 scrollHeight 를 기준으로 textarea 에 지정한다 —
textarea 의 scrollHeight 를 쓰면 두 줄쯤 더 잡혀 어긋난다(실측 830 vs 792,
브라우저에서 확인 후 수정). 20만 자를 넘으면 강조를 끈다.

4) 소스 정리(포맷)와 저장 반영

store.format_html 을 추가했다. 화면 표시와 저장에 같은 함수를 쓰므로 화면에서 본
정리된 소스가 그대로 카페24에 저장된다.

렌더링을 바꾸지 않는 것을 최우선으로 했다. HTML 에서 공백은 의미가 있어서 인라인
요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다 — 이미지 사이가 벌어지는 고전적인
사고다. 그래서 블록 요소 경계에서만 줄을 나누고 img·br·span·a 는 블록 목록에서
일부러 뺐다. <style>·<script>·<pre>·<textarea> 안쪽은 한 글자도 건드리지 않는다.
내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다.

멱등성을 테스트로 고정했다. 처음 구현은 <style> 안 빈 줄이 실행마다 한 줄씩 늘어나
멱등이 깨졌고(테스트가 잡음), 앞뒤 빈 줄을 버리도록 고쳤다. 편집하지 않고 다시
적용해도 저장값이 계속 달라지면 버전 이력이 의미를 잃는다.

닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 상한(12)을 뒀고, 어떤 이유로든 실패하면
원본을 그대로 돌려준다.

검증: 유닛테스트 41개 통과(신규 8개 — 블록 분리·인라인 보존(이미지 붙음)·style
원문 보존·멱등·짧은 블록 합치기·깨진 HTML 내성·속성값 미변경·정리+인코딩 왕복).
브라우저 실측: 검색란 32px, 목록 550px/편집기 750px, 오버레이 두 층 높이 일치
(편집 전 792=792, 20줄 추가 후 1175=1175), 토큰 색상 적용, 가로 스크롤 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:48:23 +09:00
king 119a128ee0 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>
2026-08-14 12:34:52 +09:00
king 7a2e933c16 feat(cafe24): 상세페이지 HTML 편집·적용 + 이미지 경로 한글 표시
1) 한글 파일명이 %EC%9A%A9… 으로 보이던 문제

카페24는 상세페이지 안 이미지 경로를 퍼센트 인코딩해서 저장한다. 화면에서는
읽을 수 없으므로 store.decode_html_urls 로 풀어 보여주고, 저장할 때
encode_html_urls 로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존된다 —
편집하지 않고 적용해도 카페24 저장값이 한 바이트도 달라지지 않는다.

깨뜨리지 않기 위한 두 가지 제약을 뒀다. 디코딩은 non-ASCII(%80~%FF)만 한다.
%20·%3C 를 풀면 URL·HTML 구조가 깨진다. 인코딩은 src/href/poster/data-src 와
CSS url() 안의 값만 한다. 본문 한글 텍스트를 인코딩하면 페이지가 망가진다.
UTF-8 로 해석되지 않는 이스케이프(EUC-KR 등)는 건드리지 않고 그대로 둔다.

2) 편집 후 적용

POST /cafe24/products/{no}/apply 는 이 순서를 지킨다.
  카페24 현재값 재조회 → BACKUP revision → 지문 대조 → PUT → MANUAL revision
현재값을 다시 읽는 것은 로컬 DB 의 마지막 버전이 지금 카페24에 올라간 값이라고
믿을 수 없기 때문이다(관리자 페이지에서 직접 고쳤을 수 있다). 지문(sha256 앞
32자)은 편집 중 남이 바꾼 내용을 조용히 덮어쓰는 것을 막는 낙관적 잠금이다.

미분리 상품(separated_mobile_description='F')은 모바일 필드도 같은 HTML 로
함께 쓴다. PC 만 바꾸면 모바일 상세가 어긋난다. 분리 상품은 모바일을 건드리지
않고 화면에 별도 반영 안내를 띄운다.

빈 내용은 거부한다(상세페이지를 통째로 날리는 실수 방지). 변경이 없으면 API 를
호출하지 않는다. 실패 시에도 BACKUP 은 남아 있으므로 오류 메시지에 버전 번호를
알려준다. 편집 중 페이지 이탈 경고도 넣었다.

버전 이력 표를 상세 화면에 붙였다(목록 조회는 html_content 를 제외하고 길이만
계산한다 — 수 MB 가 될 수 있다). 버전 선택 복원은 Phase 6.

검증: 유닛테스트 30개 통과(신규 7개 — 실제 파일명으로 왕복 동일성, ASCII
이스케이프 미변환, 본문 한글 보존, CSS url(), 잘못된 UTF-8 무시, 지문).
실제 쓰기(PUT)는 서버 배포 후 테스트 상품 1건으로 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:14:42 +09:00
king 07626bcaf8 feat(cafe24): 상품 목록·검색 + 현재 상세페이지 HTML 조회 (Phase 2)
상세설명 API 경로가 틀려 있던 것을 실물 확인으로 바로잡았다.
`/admin/products/{no}/description` 은 존재하지 않는다(운영몰 호출 결과
`No API found.`). 상세설명은 상품 리소스의 필드이므로 GET/PUT 을
`/admin/products/{no}` 로 옮겼고, PUT body 는 {"request": {...}} 다.

PC/모바일 상세설명이 별도 필드라는 것도 확인됐다. `separated_mobile_description`
('T'/'F') 이 분리 사용 여부이며, 미분리 상품을 수정할 때 description 만 바꾸면
모바일이 어긋난다. Descriptions 데이터클래스에 이 플래그와 불일치 여부를 담아
화면에서 경고로 노출한다.

목록 응답에는 description 이 없어(확인됨) 상세설명은 상품 1건씩 조회한다.
그래서 목록 화면에 미리보기를 뿌리지 않는다 — 상품 87개면 87호출이라 호출
제한에 걸린다.

화면은 읽기 전용이다(편집·적용은 Phase 3~4). 목록은 카페24를 매번 조회해
현재값을 보여주고, 결과를 cafe24_products 에 UPSERT 해둔다(예약·로그 화면에서
API 없이 상품명을 쓰기 위함).

상단 탭의 예약관리가 404 였으므로 Phase 5 안내 화면을 붙였다.

토큰 만료 시각이 화면에 +00:00 로 보이던 것도 고쳤다. 컬럼이 timestamptz 라
psycopg 가 UTC 로 돌려주는 값을 그대로 출력하고 있었다(시각 자체는 정확했다).

검증: 유닛테스트 23개 통과(신규 7개 — 상세설명 경로가 /description 으로
되돌아가지 않는지, PUT payload 모양, 미분리 플래그 파싱, 페이징 clamp).
라우트 8개 등록 확인. 실제 화면은 서버 배포 후 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:05:19 +09:00
king 4be7c7f580 fix(cafe24): 페이지 내용이 잘려 [카페24 연결] 버튼이 안 보이던 문제
.erp-page 는 flex column + overflow-y:auto 인데 자식의 flex-shrink 기본값이
1 이라, 내용이 화면 높이보다 길어지면 카드가 눌려 아래쪽이 잘렸다. 넘친 게
없다고 판단되어 스크롤바도 생기지 않아 시스템 화면의 연결 버튼에 접근할 수
없었다.

카페24 템플릿에서만 로드되는 cafe24.css 에 `.erp-page > * { flex-shrink: 0 }`
를 넣어 축소를 막았다. 전역 erp-shell.css 를 고치지 않은 것은 다른 모듈
(휴가·쿠팡 등)이 .vac/.cpg 로 "한 화면 채움" 레이아웃을 쓰고 있어 회귀 위험이
있기 때문이다.

캐시된 CSS 가 쓰이지 않게 두 템플릿의 버전 쿼리를 20260814a→b 로 올렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:04:08 +09:00
king c6fb8ed375 feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를
편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통
Integration 계층, cafe24_db, OAuth 연결 화면까지다.

카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고
app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장
일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기
때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서
재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다.

토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가
유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와
연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다.

토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh
token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가
동시에 갱신하면 한쪽 토큰이 무효화된다.

기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트·
테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None
이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다.

가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를
include 하는 구조에서 순환 import 가 생기기 때문이다.

검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시
토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과.
cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:23:02 +09:00
32 changed files with 5024 additions and 0 deletions
+22
View File
@@ -64,6 +64,28 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송. # 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr # PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
# ─── 카페24 상품 상세페이지 관리 모듈 (cafe24_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마 생성: scripts/sql/cafe24_db_init.sql 참고.
# 권한키: cafe24(접근). admin 은 항상 통과. 카페24 연결(OAuth)은 admin 전용.
# CAFE24_DB_URL=postgresql://cafe24_app:replace-me@postgres-db:5432/cafe24_db
#
# 카페24 개발자센터(https://developers.cafe24.com)에서 앱을 만들고 발급받은 값.
# - Redirect URI 는 아래 CAFE24_REDIRECT_URI 와 반드시 동일하게 앱에 등록.
# - Scope 는 상품관리에 mall.read_product, mall.write_product 가 필요하다.
# (향후 주문관리 추가 시 mall.read_order, mall.write_order 를 앱에 추가하고
# 재인증하면 된다 — 코드는 app/integrations/cafe24/config.py 의 SCOPES)
# CAFE24_MALL_ID=miraskitchen
# CAFE24_CLIENT_ID=
# CAFE24_CLIENT_SECRET=
# CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
# CAFE24_API_VERSION=2026-03-01
#
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
# 카페24 재연결(재인증)이 필요하다.
# CAFE24_TOKEN_SECRET=
# ─── 상품 검색 (itemcode_db 읽기 전용) ─── # ─── 상품 검색 (itemcode_db 읽기 전용) ───
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능. # 미설정 시 검색 비활성 → 수동 등록만 가능.
+1
View File
@@ -35,6 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` - 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md` - 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`. 상세는 `docs/PROJECT_OVERVIEW.md`.
+5
View File
@@ -0,0 +1,5 @@
"""외부 서비스 연동 공통 계층.
모듈(app/modules/*)에 종속되지 않는 재사용 가능한 API 클라이언트를 둔다.
현재: cafe24 (상품관리 + 향후 주문관리가 공유).
"""
+95
View File
@@ -0,0 +1,95 @@
"""카페24 연동 공통 계층 (상품관리 + 향후 주문관리 공유).
구성
config.py 환경변수 → Cafe24Config (하드코딩 금지)
crypto.py 토큰 Fernet 암복호화
oauth.py 인증 URL / code→token / refresh
tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
client.py Cafe24Client — 전송·재시도·429/5xx·API 로그
products.py 상품 엔드포인트 래퍼
errors.py 공통 예외
사용 예 (모듈 라우터에서):
from app.integrations.cafe24 import build_cafe24_api
api = build_cafe24_api(store) # store = Cafe24Store
html = products.get_description(api.client, 123)
CAFE24_* 환경변수가 없어도 import 는 성공한다. 실제 호출 시점에
Cafe24ConfigError 가 나며, 라우터가 "설정 필요" 안내를 보여준다.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from . import products
from .client import Cafe24Client
from .config import (
DEFAULT_SCOPES,
DESIGN_SCOPES,
ORDER_SCOPES,
PRODUCT_SCOPES,
Cafe24Config,
load_config,
)
from .errors import (
Cafe24ApiError,
Cafe24AuthError,
Cafe24ConfigError,
Cafe24Error,
Cafe24RateLimitError,
)
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
from .tokens import TokenService
__all__ = [
"Cafe24Api",
"build_cafe24_api",
"Cafe24Client",
"Cafe24Config",
"TokenService",
"TokenBundle",
"load_config",
"build_authorize_url",
"exchange_code",
"refresh_tokens",
"new_state",
"products",
"PRODUCT_SCOPES",
"ORDER_SCOPES",
"DESIGN_SCOPES",
"DEFAULT_SCOPES",
"Cafe24Error",
"Cafe24ConfigError",
"Cafe24AuthError",
"Cafe24RateLimitError",
"Cafe24ApiError",
]
@dataclass(frozen=True)
class Cafe24Api:
"""설정 + 토큰서비스 + 클라이언트 묶음. 라우터/worker 가 이것만 들고 다닌다."""
config: Cafe24Config
tokens: TokenService
client: Cafe24Client
def build_cafe24_api(store: Any, *, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Api:
"""Cafe24Store 를 저장소로 쓰는 API 묶음 생성.
store 는 토큰 3개 메서드(get_token_row/save_token_row/token_lock)와
API 로그 기록용 log_api_call 을 제공해야 한다.
"""
config = load_config(scopes=scopes)
token_service = TokenService(store, config)
client = Cafe24Client(
config,
token_service,
api_logger=getattr(store, "log_api_call", None),
)
return Cafe24Api(config=config, tokens=token_service, client=client)
+258
View File
@@ -0,0 +1,258 @@
"""카페24 Admin API 전송 계층.
라우터/서비스는 httpx 를 직접 쓰지 않고 이 클라이언트만 쓴다.
여기서 처리하는 것:
- Authorization 헤더 부착 (TokenService 가 만료 시 자동 갱신)
- X-Cafe24-Api-Version 헤더
- timeout
- 401 → 토큰 1회 강제 갱신 후 재시도
- 429 → Retry-After 존중, 제한 횟수만큼 대기 후 재시도
- 5xx / 네트워크 오류 → 지수 백오프 재시도
- 호출당 최소 간격 유지(대량 작업이 한 번에 몰리지 않게)
- API 로그 기록 (토큰/시크릿은 절대 기록하지 않음)
동기(sync) 클라이언트다. 예약 worker 가 평범한 스크립트이고, 라우터에서는
`async def` 대신 `def` 핸들러로 선언해 FastAPI 의 스레드풀에서 실행하면
이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Callable
import httpx
from .config import Cafe24Config
from .errors import (
Cafe24ApiError,
Cafe24AuthError,
Cafe24ConfigError,
Cafe24RateLimitError,
)
from .tokens import TokenService
logger = logging.getLogger("cafe24.client")
DEFAULT_TIMEOUT = 30.0
DEFAULT_MAX_RETRIES = 3
# 카페24 호출 사이 최소 간격(초). 대량 수정 시 429 를 미리 피한다.
DEFAULT_MIN_INTERVAL = 0.35
# api_logger(endpoint, method, product_no, http_status, result, error_message, duration_ms)
ApiLogger = Callable[..., None]
class Cafe24Client:
def __init__(
self,
config: Cafe24Config,
token_service: TokenService,
*,
api_logger: ApiLogger | None = None,
timeout: float = DEFAULT_TIMEOUT,
max_retries: int = DEFAULT_MAX_RETRIES,
min_interval: float = DEFAULT_MIN_INTERVAL,
):
self._config = config
self._tokens = token_service
self._api_logger = api_logger
self._timeout = timeout
self._max_retries = max_retries
self._min_interval = min_interval
self._pace_lock = threading.Lock()
self._last_call = 0.0
# ────────────────────────────────────────────────────────────
# 내부 헬퍼
# ────────────────────────────────────────────────────────────
def _pace(self) -> None:
"""호출 간 최소 간격 확보 (스레드 안전)."""
with self._pace_lock:
gap = time.monotonic() - self._last_call
if gap < self._min_interval:
time.sleep(self._min_interval - gap)
self._last_call = time.monotonic()
def _headers(self, access_token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": self._config.api_version,
}
def _log(
self,
*,
endpoint: str,
method: str,
product_no: int | None,
http_status: int | None,
result: str,
error_message: str,
duration_ms: int,
) -> None:
if self._api_logger is None:
return
try:
self._api_logger(
endpoint=endpoint,
method=method,
product_no=product_no,
http_status=http_status,
result=result,
error_message=error_message[:500],
duration_ms=duration_ms,
)
except Exception: # noqa: BLE001 — 로그 실패가 본 작업을 막으면 안 된다.
logger.exception("카페24 API 로그 기록 실패")
@staticmethod
def _error_message(response: httpx.Response) -> str:
"""카페24 오류 응답에서 사람이 읽을 메시지만 뽑는다."""
try:
payload = response.json()
except ValueError:
return response.text[:500]
error = payload.get("error")
if isinstance(error, dict):
parts = [str(error.get("message") or "")]
detail = error.get("details")
if isinstance(detail, list) and detail:
parts.append("; ".join(str(d.get("message", d)) for d in detail[:3]))
message = " / ".join(p for p in parts if p)
if message:
return message[:500]
return str(payload)[:500]
@staticmethod
def _retry_after(response: httpx.Response, *, attempt: int) -> float:
raw = (response.headers.get("Retry-After") or "").strip()
if raw:
try:
return max(0.5, float(raw))
except ValueError:
pass
return min(8.0, 0.5 * (2**attempt))
# ────────────────────────────────────────────────────────────
# 공개 API
# ────────────────────────────────────────────────────────────
def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
product_no: int | None = None,
) -> dict[str, Any]:
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
if not self._config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
)
endpoint = path if path.startswith("/") else f"/{path}"
url = f"{self._config.api_base}{endpoint}"
method = method.upper()
forced_refresh = False
last_error: Exception | None = None
for attempt in range(self._max_retries + 1):
self._pace()
started = time.monotonic()
status: int | None = None
try:
access_token = self._tokens.get_access_token()
with httpx.Client(timeout=self._timeout) as client:
response = client.request(
method,
url,
headers=self._headers(access_token),
params=params,
json=json,
)
status = response.status_code
elapsed = int((time.monotonic() - started) * 1000)
if 200 <= status < 300:
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="SUCCESS", error_message="",
duration_ms=elapsed,
)
try:
return response.json()
except ValueError:
return {}
message = self._error_message(response)
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="FAIL", error_message=message,
duration_ms=elapsed,
)
if status == 401 and not forced_refresh:
# 서버가 토큰을 먼저 무효화한 경우 — 1회만 강제 갱신 후 재시도.
forced_refresh = True
self._tokens.force_expire()
last_error = Cafe24AuthError("카페24 인증이 만료되어 갱신 후 재시도합니다.")
continue
if status == 401:
raise Cafe24AuthError(
"카페24 인증에 실패했습니다. 시스템 → 카페24 연결에서 재인증하세요.",
needs_reauth=True,
)
if status == 429:
wait = self._retry_after(response, attempt=attempt)
last_error = Cafe24RateLimitError(
f"카페24 API 호출 제한(429). {wait:.1f}초 후 재시도합니다.",
retry_after=wait,
)
if attempt >= self._max_retries:
raise last_error
time.sleep(wait)
continue
error = Cafe24ApiError(message, status=status, endpoint=endpoint)
if error.retryable and attempt < self._max_retries:
last_error = error
time.sleep(min(8.0, 0.5 * (2**attempt)))
continue
raise error
except (Cafe24AuthError, Cafe24RateLimitError, Cafe24ApiError, Cafe24ConfigError):
raise
except httpx.HTTPError as exc:
elapsed = int((time.monotonic() - started) * 1000)
message = f"네트워크 오류 ({type(exc).__name__})"
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="ERROR", error_message=message,
duration_ms=elapsed,
)
last_error = Cafe24ApiError(message, status=0, endpoint=endpoint)
if attempt < self._max_retries:
time.sleep(min(8.0, 0.5 * (2**attempt)))
continue
raise last_error from None
# 재시도를 모두 소진 (401 강제갱신 루프 포함)
if last_error:
raise last_error
raise Cafe24ApiError("카페24 API 호출에 실패했습니다.", endpoint=endpoint)
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("GET", path, **kwargs)
def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("PUT", path, **kwargs)
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("POST", path, **kwargs)
+85
View File
@@ -0,0 +1,85 @@
"""카페24 연동 설정 — 환경변수만 읽는다(하드코딩 금지).
app/main.py 의 env() 헬퍼와 동일하게 os.getenv + strip 규칙을 쓴다.
integrations 계층은 app.main 을 import 하지 않는다(순환 import 방지).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
# 상품관리에 필요한 최소 scope. 향후 주문관리는 ORDER_SCOPES 를 더한다.
PRODUCT_SCOPES: tuple[str, ...] = ("mall.read_product", "mall.write_product")
ORDER_SCOPES: tuple[str, ...] = ("mall.read_order", "mall.write_order")
# 디자인(테마) — 상단 공통 홍보 숨김을 스킨 쪽에서 처리할 수 있는지 확인/구현용.
# ⚠️ 카페24 Admin API 에는 스킨 HTML 파일(detail.html)을 직접 읽거나 쓰는
# 엔드포인트가 없다. 조회 가능한 것은 테마 목록과 테마 페이지뿐이다.
# 스크립트 태그 주입은 디자인이 아니라 mall.write_store(상점) 권한이며
# 인라인 코드가 아닌 외부 HTTPS URL 만 받는다.
DESIGN_SCOPES: tuple[str, ...] = ("mall.read_design", "mall.write_design")
# 실제로 요청하는 scope 묶음. 개발자센터 앱에 등록된 권한과 어긋나면 인증이
# 거부되므로, 여기에 추가할 때는 앱 권한도 함께 확인해야 한다.
DEFAULT_SCOPES: tuple[str, ...] = PRODUCT_SCOPES + DESIGN_SCOPES
DEFAULT_API_VERSION = "2026-03-01"
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, "").strip()
return value if value else default
@dataclass(frozen=True)
class Cafe24Config:
mall_id: str
client_id: str
client_secret: str
redirect_uri: str
api_version: str
token_secret: str
scopes: tuple[str, ...]
@property
def configured(self) -> bool:
"""OAuth 를 시작할 수 있는 최소 조건."""
return bool(self.mall_id and self.client_id and self.client_secret and self.redirect_uri)
@property
def missing(self) -> list[str]:
"""설정 안내 화면에 표시할 미설정 환경변수 이름들."""
pairs = (
("CAFE24_MALL_ID", self.mall_id),
("CAFE24_CLIENT_ID", self.client_id),
("CAFE24_CLIENT_SECRET", self.client_secret),
("CAFE24_REDIRECT_URI", self.redirect_uri),
("CAFE24_TOKEN_SECRET", self.token_secret),
)
return [name for name, value in pairs if not value]
@property
def api_base(self) -> str:
return f"https://{self.mall_id}.cafe24api.com/api/v2"
@property
def scope_param(self) -> str:
return ",".join(self.scopes)
def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
"""환경변수에서 설정을 읽는다. 값이 없어도 예외를 던지지 않는다.
미설정 판단은 호출부가 `configured` / `missing` 으로 한다
(앱 기동을 막지 않기 위해 — 다른 모듈과 동일한 정책).
"""
return Cafe24Config(
mall_id=_env("CAFE24_MALL_ID"),
client_id=_env("CAFE24_CLIENT_ID"),
client_secret=_env("CAFE24_CLIENT_SECRET"),
redirect_uri=_env("CAFE24_REDIRECT_URI"),
api_version=_env("CAFE24_API_VERSION", DEFAULT_API_VERSION),
token_secret=_env("CAFE24_TOKEN_SECRET"),
scopes=scopes,
)
+51
View File
@@ -0,0 +1,51 @@
"""토큰 암호화 — Fernet(AES-128-CBC + HMAC).
DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 한다.
키는 .env 의 CAFE24_TOKEN_SECRET 하나이며, 임의 길이 문자열을 받아
SHA-256 으로 32바이트를 만든 뒤 Fernet 키 형식으로 변환한다
(운영자가 `openssl rand -hex 32` 같은 익숙한 방식을 그대로 쓰게 하려는 것).
⚠️ CAFE24_TOKEN_SECRET 을 바꾸면 기존 저장 토큰은 복호화할 수 없다.
그 경우 관리자 화면에서 카페24 재연결(재인증)을 하면 된다.
"""
from __future__ import annotations
import base64
import hashlib
from .errors import Cafe24ConfigError
def _fernet(secret: str):
from cryptography.fernet import Fernet # 지연 import
if not (secret or "").strip():
raise Cafe24ConfigError(
"CAFE24_TOKEN_SECRET 환경변수가 설정되지 않았습니다. "
"openssl rand -hex 32 로 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요."
)
digest = hashlib.sha256(secret.strip().encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt(value: str, *, secret: str) -> str:
"""평문 → 암호문. 빈 문자열은 그대로 둔다(미연결 상태 표현)."""
if not value:
return ""
return _fernet(secret).encrypt(value.encode("utf-8")).decode("ascii")
def decrypt(value: str, *, secret: str) -> str:
"""암호문 → 평문. 키가 바뀌었거나 손상되면 Cafe24ConfigError."""
if not value:
return ""
from cryptography.fernet import InvalidToken # 지연 import
try:
return _fernet(secret).decrypt(value.encode("ascii")).decode("utf-8")
except InvalidToken:
raise Cafe24ConfigError(
"저장된 카페24 토큰을 복호화하지 못했습니다. "
"CAFE24_TOKEN_SECRET 이 변경되었을 수 있습니다. 카페24 재연결이 필요합니다."
) from None
+45
View File
@@ -0,0 +1,45 @@
"""카페24 연동 공통 예외.
라우터/서비스는 httpx 예외를 직접 다루지 않고 여기 정의된 타입만 잡는다.
모든 메시지는 사용자에게 그대로 노출될 수 있으므로 토큰/시크릿을 담지 않는다.
"""
from __future__ import annotations
class Cafe24Error(Exception):
"""카페24 연동 최상위 예외."""
class Cafe24ConfigError(Cafe24Error):
"""CAFE24_* 환경변수 미설정 등 설정 문제."""
class Cafe24AuthError(Cafe24Error):
"""인증 실패 — 토큰 없음/만료/refresh 불가. 재인증이 필요하다."""
def __init__(self, message: str, *, needs_reauth: bool = False):
super().__init__(message)
self.needs_reauth = needs_reauth
class Cafe24RateLimitError(Cafe24Error):
"""429 Too Many Requests. retry_after 초 뒤 재시도 가능."""
def __init__(self, message: str, *, retry_after: float = 1.0):
super().__init__(message)
self.retry_after = retry_after
class Cafe24ApiError(Cafe24Error):
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
def __init__(self, message: str, *, status: int = 0, endpoint: str = ""):
super().__init__(message)
self.status = status
self.endpoint = endpoint
@property
def retryable(self) -> bool:
"""5xx 와 타임아웃(status=0)만 재시도 대상. 4xx 는 고쳐야 할 요청."""
return self.status == 0 or self.status >= 500
+145
View File
@@ -0,0 +1,145 @@
"""카페24 OAuth 2.0 (Authorization Code) — URL 생성 / 토큰 발급 / 갱신.
토큰 저장은 여기서 하지 않는다(tokens.TokenService 담당). 이 모듈은 순수하게
카페24 인증 엔드포인트와만 대화한다.
카페24 토큰 응답의 만료시각(`expires_at`, `refresh_token_expires_at`)은
타임존 표기가 없는 KST 문자열이므로 KST 를 붙여 aware datetime 으로 만든다.
"""
from __future__ import annotations
import base64
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from urllib.parse import urlencode
import httpx
from app.timezone import KST, now_kst
from .config import Cafe24Config
from .errors import Cafe24AuthError, Cafe24ConfigError
TOKEN_TIMEOUT = 20.0
@dataclass(frozen=True)
class TokenBundle:
"""카페24가 돌려준 토큰 한 벌 (평문 — 저장 직전에 암호화된다)."""
access_token: str
refresh_token: str
access_token_expires_at: datetime
refresh_token_expires_at: datetime | None
scopes: str
def new_state() -> str:
"""CSRF 방어용 state. 세션에 넣어두고 콜백에서 대조한다."""
return secrets.token_urlsafe(24)
def build_authorize_url(config: Cafe24Config, *, state: str) -> str:
if not config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(config.missing)
)
query = urlencode(
{
"response_type": "code",
"client_id": config.client_id,
"redirect_uri": config.redirect_uri,
"scope": config.scope_param,
"state": state,
}
)
return f"{config.api_base}/oauth/authorize?{query}"
def _basic_auth_header(config: Cafe24Config) -> str:
raw = f"{config.client_id}:{config.client_secret}".encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def _parse_expiry(value: str | None, *, fallback_seconds: int) -> datetime:
"""'2026-08-20T14:00:00.000' → KST aware datetime. 실패 시 fallback."""
text = (value or "").strip()
if text:
try:
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
except ValueError:
pass
return now_kst() + timedelta(seconds=fallback_seconds)
def _to_bundle(payload: dict) -> TokenBundle:
access = (payload.get("access_token") or "").strip()
refresh = (payload.get("refresh_token") or "").strip()
if not access:
raise Cafe24AuthError("카페24 응답에 access_token 이 없습니다.", needs_reauth=True)
scopes = payload.get("scopes")
if isinstance(scopes, list):
scope_text = ",".join(str(s) for s in scopes)
else:
scope_text = str(scopes or "")
return TokenBundle(
access_token=access,
refresh_token=refresh,
# access token 은 통상 2시간, refresh token 은 2주.
access_token_expires_at=_parse_expiry(payload.get("expires_at"), fallback_seconds=7200),
refresh_token_expires_at=(
_parse_expiry(payload.get("refresh_token_expires_at"), fallback_seconds=1209600)
if refresh
else None
),
scopes=scope_text,
)
def _post_token(config: Cafe24Config, data: dict[str, str]) -> TokenBundle:
url = f"{config.api_base}/oauth/token"
headers = {
"Authorization": _basic_auth_header(config),
"Content-Type": "application/x-www-form-urlencoded",
}
try:
with httpx.Client(timeout=TOKEN_TIMEOUT) as client:
response = client.post(url, headers=headers, data=data)
except httpx.HTTPError as exc:
# 예외 문자열에 Authorization 헤더가 들어가지 않도록 타입명만 남긴다.
raise Cafe24AuthError(f"카페24 인증 서버에 연결하지 못했습니다. ({type(exc).__name__})") from None
if response.status_code != 200:
# 400/401 = 코드/리프레시토큰 무효 → 재인증 필요.
raise Cafe24AuthError(
f"카페24 토큰 요청이 거부되었습니다. (HTTP {response.status_code})",
needs_reauth=response.status_code in (400, 401),
)
return _to_bundle(response.json())
def exchange_code(config: Cafe24Config, *, code: str) -> TokenBundle:
"""authorization code → 최초 토큰."""
return _post_token(
config,
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config.redirect_uri,
},
)
def refresh_tokens(config: Cafe24Config, *, refresh_token: str) -> TokenBundle:
"""refresh token → 새 토큰 한 벌 (refresh token 도 함께 회전된다)."""
if not (refresh_token or "").strip():
raise Cafe24AuthError("저장된 refresh token 이 없습니다.", needs_reauth=True)
return _post_token(
config,
{"grant_type": "refresh_token", "refresh_token": refresh_token},
)
+216
View File
@@ -0,0 +1,216 @@
"""카페24 상품 엔드포인트 래퍼.
전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
상세설명은 **별도 리소스가 아니다.** 실제 쇼핑몰(miraskitchen)에 확인한 결과
`/admin/products/{no}/description` 은 존재하지 않는다(`No API found.`).
상세설명은 상품 리소스의 필드로 읽고 쓴다.
GET /admin/products/{no} → description · mobile_description ·
separated_mobile_description
PUT /admin/products/{no}{"request": {"description": ...}}
목록 API(`/admin/products`) 응답에는 description 이 **없다**. 그래서 상세설명은
상품 1건씩 조회해야 한다(목록 화면에서 미리보기를 뿌리지 않는 이유).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .client import Cafe24Client
# 카페24 상품 목록 API 의 1회 최대 조회 수
PAGE_LIMIT = 100
def _flag(value: Any, *, default: bool = True) -> bool:
"""카페24는 boolean 을 'T'/'F' 문자열로 준다."""
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "Y", "1"):
return True
if text in ("F", "FALSE", "N", "0"):
return False
return default
def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
params: dict[str, Any] = {}
if product_name:
params["product_name"] = product_name
payload = client.get("/admin/products/count", params=params)
try:
return int(payload.get("count") or 0)
except (TypeError, ValueError):
return 0
def list_products(
client: Cafe24Client,
*,
limit: int = PAGE_LIMIT,
offset: int = 0,
product_name: str = "",
product_no: int | None = None,
) -> list[dict[str, Any]]:
"""상품 목록 1페이지. 검색어가 있으면 상품명 부분일치로 조회한다."""
params: dict[str, Any] = {
"limit": max(1, min(int(limit), PAGE_LIMIT)),
"offset": max(0, int(offset)),
}
if product_name:
params["product_name"] = product_name
if product_no:
params["product_no"] = int(product_no)
payload = client.get("/admin/products", params=params)
products = payload.get("products")
return products if isinstance(products, list) else []
def list_all_products(
client: Cafe24Client,
*,
product_name: str = "",
max_items: int = 1000,
) -> tuple[list[dict[str, Any]], bool]:
"""전체 상품을 페이지를 넘겨가며 모두 가져온다.
2분할 화면의 왼쪽 목록은 페이지 없이 한 번에 보여주고 필터·정렬을 브라우저에서
처리한다. 그래야 "진열중만" 같은 필터가 전체 기준으로 정확해진다
(한 페이지만 받아 걸러내면 다음 페이지의 해당 상품이 빠진다).
반환: (상품 목록, 상한에 걸려 잘렸는지)
상품이 max_items 를 넘으면 거기서 멈춘다 — 무한 호출로 API 제한에 걸리는
것을 막기 위한 안전장치다(현재 쇼핑몰 87개, 1회 100개 조회).
"""
collected: list[dict[str, Any]] = []
while len(collected) < max_items:
want = min(PAGE_LIMIT, max_items - len(collected))
batch = list_products(
client,
limit=want,
offset=len(collected),
product_name=product_name,
)
collected.extend(batch)
if len(batch) < want:
return collected, False # 요청한 만큼 못 받았다 = 마지막 페이지
if len(collected) >= max_items:
return collected, True # 상한에서 멈췄다 — 뒤에 더 있을 수 있다
return collected, False
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
no = int(product_no)
payload = client.get(f"/admin/products/{no}", product_no=no)
product = payload.get("product")
return product if isinstance(product, dict) else {}
@dataclass(frozen=True)
class Descriptions:
"""상품 1건의 상세설명 묶음. 카페24가 언제나 source of truth 다."""
product_no: int
product_name: str
description: str
mobile_description: str
# separated_mobile_description = 'T' 면 PC/모바일 상세설명을 따로 쓴다.
# 'F' 면 모바일도 PC 값을 쓰므로 수정 시 두 필드를 함께 맞춰야 한다.
separated_mobile: bool
@property
def mobile_differs(self) -> bool:
return self.mobile_description != self.description
def descriptions_from_product(raw: dict[str, Any]) -> Descriptions:
"""`get_product` 응답 dict → Descriptions."""
try:
product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError):
product_no = 0
return Descriptions(
product_no=product_no,
product_name=str(raw.get("product_name") or ""),
description=str(raw.get("description") or ""),
mobile_description=str(raw.get("mobile_description") or ""),
separated_mobile=_flag(raw.get("separated_mobile_description"), default=False),
)
def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions:
"""상품의 현재 상세설명. 로컬 DB 의 마지막 버전을 현재값으로 가정하지 않는다."""
return descriptions_from_product(get_product(client, product_no))
def build_update_payload(
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
`mobile_description=None` 이면 모바일 필드를 건드리지 않는다. PC/모바일
미분리(separated_mobile=False) 상품은 호출부가 같은 HTML 을 두 번 넘겨
두 필드를 함께 맞춘다.
"""
request: dict[str, Any] = {"description": description}
if mobile_description is not None:
request["mobile_description"] = mobile_description
payload: dict[str, Any] = {"request": request}
if shop_no:
payload["shop_no"] = int(shop_no)
return payload
def update_descriptions(
client: Cafe24Client,
product_no: int,
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상세설명 교체. 성공하면 카페24가 돌려준 상품 dict.
실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로 호출부는 예외가 없을
때만 성공으로 처리하면 된다.
⚠️ 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 BACKUP revision 을 남길
것(`docs/CAFE24_MODULE.md` 보안 규칙). 이 함수는 백업을 하지 않는다.
"""
no = int(product_no)
payload = client.put(
f"/admin/products/{no}",
json=build_update_payload(
description=description,
mobile_description=mobile_description,
shop_no=shop_no,
),
product_no=no,
)
product = payload.get("product")
return product if isinstance(product, dict) else payload
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
try:
product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError):
product_no = 0
return {
"product_no": product_no,
"product_code": str(raw.get("product_code") or ""),
"product_name": str(raw.get("product_name") or ""),
"display": _flag(raw.get("display")),
"selling": _flag(raw.get("selling")),
}
+185
View File
@@ -0,0 +1,185 @@
"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신.
저장소(repo)는 duck typing 으로 주입한다. 실제 구현은
`app/modules/cafe24/db.py` 의 Cafe24Store 이며, 아래 3개만 있으면 된다.
repo.get_token_row(mall_id) -> dict | None (암호문 그대로)
repo.save_token_row(**fields)-> None (UPSERT)
repo.token_lock(mall_id) -> contextmanager (FOR UPDATE, .row / .save())
`token_lock` 은 web 컨테이너와 worker 컨테이너가 동시에 refresh 를 시도해도
한쪽만 카페24에 요청하도록 행 잠금을 건다(카페24는 refresh token 을 회전시키므로
동시 refresh 시 한쪽 토큰이 무효화된다).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from app.timezone import KST, now_kst
from .config import Cafe24Config
from .crypto import decrypt, encrypt
from .errors import Cafe24AuthError
from .oauth import TokenBundle, refresh_tokens
logger = logging.getLogger("cafe24.tokens")
# 만료 몇 초 전부터 미리 갱신할지 (네트워크 지연 여유)
REFRESH_MARGIN = timedelta(seconds=120)
class TokenService:
def __init__(self, repo: Any, config: Cafe24Config):
self._repo = repo
self._config = config
# ────────────────────────────────────────────────────────────
# 저장
# ────────────────────────────────────────────────────────────
def save_bundle(self, bundle: TokenBundle, *, connected_by: str = "") -> None:
"""최초 인증/재인증 후 토큰 저장. 토큰은 암호화해서 넣는다."""
secret = self._config.token_secret
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
connected_by=connected_by,
)
# ────────────────────────────────────────────────────────────
# 조회
# ────────────────────────────────────────────────────────────
def _aware(self, value: Any):
"""DB 에서 온 datetime 을 KST aware 로 정규화.
컬럼이 timestamptz 라 psycopg 는 UTC 로 돌려준다. 시각 자체는 같지만
화면에 `+00:00` 으로 보이므로 KST 로 변환해 다른 모듈과 표기를 맞춘다.
"""
if value is None:
return None
aware = value if value.tzinfo else value.replace(tzinfo=KST)
return aware.astimezone(KST)
def status(self) -> dict[str, Any]:
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""
if not self._config.configured:
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": False,
"reason": "환경변수 미설정",
}
row = self._repo.get_token_row(self._config.mall_id)
if not row or not row.get("access_token"):
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": True,
"reason": "아직 카페24 연결(인증)을 하지 않았습니다.",
}
access_exp = self._aware(row.get("access_token_expires_at"))
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
now = now_kst()
refresh_dead = bool(refresh_exp and now >= refresh_exp)
return {
"connected": not refresh_dead,
"mall_id": row.get("mall_id") or self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": refresh_dead,
"reason": "refresh token 이 만료되었습니다. 재연결이 필요합니다." if refresh_dead else "",
"scopes": row.get("scopes") or "",
"access_token_expires_at": access_exp.isoformat(timespec="seconds") if access_exp else "",
"refresh_token_expires_at": refresh_exp.isoformat(timespec="seconds") if refresh_exp else "",
"access_expired": bool(access_exp and now >= access_exp),
"last_refreshed_at": (
self._aware(row.get("last_refreshed_at")).isoformat(timespec="seconds")
if row.get("last_refreshed_at")
else ""
),
"last_error": row.get("last_error") or "",
"connected_by": row.get("connected_by") or "",
}
# ────────────────────────────────────────────────────────────
# 사용 (Cafe24Client 가 호출)
# ────────────────────────────────────────────────────────────
def get_access_token(self) -> str:
"""유효한 access token. 만료(임박)면 잠금 걸고 1회 갱신 후 반환."""
mall_id = self._config.mall_id
row = self._repo.get_token_row(mall_id)
if not row or not row.get("access_token"):
raise Cafe24AuthError(
"카페24에 연결되어 있지 않습니다. 시스템 → 카페24 연결에서 인증하세요.",
needs_reauth=True,
)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
return decrypt(row["access_token"], secret=self._config.token_secret)
return self._refresh_locked(mall_id)
def force_expire(self) -> None:
"""access token 만료시각을 과거로 밀어 다음 호출에서 반드시 갱신하게 한다.
서버가 만료 전에 토큰을 무효화해 401 이 온 경우(Cafe24Client)에 쓴다.
"""
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token_expires_at=now_kst() - timedelta(seconds=1),
)
def _refresh_locked(self, mall_id: str) -> str:
"""행 잠금 안에서 갱신. 잠금 대기 중 다른 프로세스가 이미 갱신했으면 그 값 사용."""
secret = self._config.token_secret
with self._repo.token_lock(mall_id) as handle:
row = handle.row
if not row:
raise Cafe24AuthError("카페24 토큰이 없습니다.", needs_reauth=True)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
# 잠금 대기 사이에 다른 프로세스가 갱신 완료.
return decrypt(row["access_token"], secret=secret)
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
if refresh_exp and now_kst() >= refresh_exp:
handle.save(last_error="refresh token 만료 — 재인증 필요")
raise Cafe24AuthError(
"카페24 refresh token 이 만료되었습니다. 시스템 → 카페24 연결에서 재인증하세요.",
needs_reauth=True,
)
try:
bundle = refresh_tokens(
self._config,
refresh_token=decrypt(row.get("refresh_token") or "", secret=secret),
)
except Cafe24AuthError as exc:
handle.save(last_error=str(exc))
raise
logger.info("카페24 access token 갱신 완료 (mall_id=%s)", mall_id)
handle.save(
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
)
return bundle.access_token
+19
View File
@@ -13,6 +13,8 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .modules.cafe24 import build_cafe24_store
from .modules.cafe24 import router as cafe24_router
from .modules.cupang import build_cupang_store, build_itemcode_reader from .modules.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router from .modules.cupang import router as cupang_router
from .modules.dispatch import build_dispatch_store from .modules.dispatch import build_dispatch_store
@@ -45,6 +47,7 @@ MODULE_LABELS: dict[str, str] = {
"malaysia": "말레이시아 재고관리", "malaysia": "말레이시아 재고관리",
"dispatch": "말레이시아 배송", "dispatch": "말레이시아 배송",
"project": "프로젝트 관리", "project": "프로젝트 관리",
"cafe24": "카페24 상품관리",
"expense_approver": "개인경비", "expense_approver": "개인경비",
"vacation_approver": "휴가", "vacation_approver": "휴가",
} }
@@ -120,6 +123,7 @@ _MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "malaysia" / "templates", BASE_DIR / "modules" / "malaysia" / "templates",
BASE_DIR / "modules" / "dispatch" / "templates", BASE_DIR / "modules" / "dispatch" / "templates",
BASE_DIR / "modules" / "project" / "templates", BASE_DIR / "modules" / "project" / "templates",
BASE_DIR / "modules" / "cafe24" / "templates",
] ]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader( templates.env.loader = ChoiceLoader(
@@ -170,6 +174,9 @@ app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or No
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). # 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip. # 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None) app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
# 카페24 상품관리: CAFE24_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 카페24 API 호출/토큰은 app/integrations/cafe24 공통 계층(CAFE24_* 환경변수).
app.state.cafe24_store = build_cafe24_store(dsn=env("CAFE24_DB_URL") or None)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router) app.include_router(expense_router)
@@ -178,6 +185,7 @@ app.include_router(vacation_router)
app.include_router(malaysia_router) app.include_router(malaysia_router)
app.include_router(dispatch_router) app.include_router(dispatch_router)
app.include_router(project_router) app.include_router(project_router)
app.include_router(cafe24_router)
def public_url_for(request: Request, route_name: str) -> str: def public_url_for(request: Request, route_name: str) -> str:
@@ -350,6 +358,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready", "status": "ready",
"category": "관리", "category": "관리",
}, },
{
"key": "cafe24",
"title": "카페24 상품관리",
"subtitle": "Cafe24 Products",
"description": "카페24 관리자에 들어가지 않고 상품 상세페이지를 편집·예약 적용하고 이전 버전으로 되돌립니다.",
"url": "/cafe24/",
"health_url": "/cafe24/health",
"status": "ready",
"category": "운영",
},
] ]
allowed = allowed_modules(user_rec) allowed = allowed_modules(user_rec)
for item in items: for item in items:
@@ -369,6 +387,7 @@ def _icon_svg(name: str) -> str:
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>', "malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>', "dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>', "project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
"cafe24": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10"/><path d="M7 13h7"/><path d="M7 17h4"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>', "modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
} }
body = paths.get(name, paths["modules"]) body = paths.get(name, paths["modules"])
+48
View File
@@ -0,0 +1,48 @@
"""카페24 상품 상세페이지 관리 모듈.
라우터/저장소/순수로직/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cafe24) + `routes_*.py`
- 저장소: `db.py` (cafe24_db / PostgreSQL 전용)
- 순수 로직: `store.py` (버전/예약 상수, 재시도 규칙, 검증)
- 템플릿: `templates/cafe24/`
카페24 API 호출은 이 모듈에 두지 않는다. 향후 주문관리 모듈과 공유하기 위해
`app/integrations/cafe24/` 공통 계층을 쓴다.
데이터 저장은 cafe24_db 전용이다. CAFE24_DB_URL 미설정 시
build_cafe24_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
"""
from typing import Any
from . import store
from .router import router
from .store import (
REVISION_LABELS,
REVISION_TYPES,
SCHEDULE_STATUS_LABELS,
SCHEDULE_STATUSES,
)
__all__ = [
"router",
"store",
"REVISION_TYPES",
"REVISION_LABELS",
"SCHEDULE_STATUSES",
"SCHEDULE_STATUS_LABELS",
"build_cafe24_store",
]
def build_cafe24_store(*, dsn: str | None) -> Any:
"""CAFE24_DB_URL 이 있으면 Cafe24Store, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시.
"""
if not dsn:
return None
from .db import Cafe24Store # 지연 import (개발 환경 deps 없을 수 있음)
return Cafe24Store(dsn)
+118
View File
@@ -0,0 +1,118 @@
"""카페24 모듈 공용 가드/컨텍스트 헬퍼.
router.py 와 routes_*.py 가 함께 쓴다(순환 import 방지를 위해 분리).
다른 모듈과 동일한 규칙:
- JSON API → require_user() : 401/403 HTTPException
- HTML 페이지 → guard() : 리다이렉트 / denied.html 응답 반환
app.main 은 함수 안에서 지연 import 한다(순환 import 방지).
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
MODULE_KEY = "cafe24"
MODULE_NAME = "카페24 상품관리"
CONFIG_HELP = (
"카페24 모듈이 아직 설정되지 않았습니다. "
"CAFE24_DB_URL 환경변수를 설정하고 "
"scripts/sql/cafe24_db_init.sql 로 cafe24_db 를 초기화한 뒤 "
"컨테이너를 재기동하세요."
)
def get_store(request: Request) -> Any:
return getattr(request.app.state, "cafe24_store", None)
def require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, MODULE_KEY):
raise HTTPException(status_code=403, detail=f"{MODULE_NAME} 모듈 권한이 없습니다.")
return user
def require_admin(request: Request) -> dict[str, Any]:
"""카페24 연결(OAuth)·연결 해제는 관리자만."""
from app.store import is_admin # noqa: WPS433
user = require_user(request)
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 카페24 연결을 변경할 수 있습니다.")
return user
def require_store(request: Request) -> tuple[Any, dict[str, Any]]:
"""JSON API 용 — store 미설정이면 503."""
user = require_user(request)
st = get_store(request)
if st is None:
raise HTTPException(status_code=503, detail=CONFIG_HELP)
return st, user
def render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return render_template(
request,
"denied.html",
{
"reason": CONFIG_HELP,
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active=MODULE_KEY),
},
status_code=503,
)
def guard(request: Request):
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.
반환이 tuple 이면 (store, user), 아니면 그대로 응답으로 돌려준다.
"""
from app.main import get_current_user_record, render_template # noqa: WPS433
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, MODULE_KEY):
return render_template(
request,
"denied.html",
{
"reason": f"{MODULE_NAME} 접근 권한이 없습니다.",
"user": user,
"is_admin": is_admin(user),
},
status_code=403,
)
st = get_store(request)
if st is None:
return render_config_needed(request, user)
return st, user
def base_ctx(request: Request, user: dict[str, Any], *, active_tab: str = "") -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return {
"user": user,
"is_admin": is_admin(user),
"is_super": bool(user.get("is_super_admin")),
"nav_items": build_erp_nav(user, active=MODULE_KEY),
"active_tab": active_tab,
}
+339
View File
@@ -0,0 +1,339 @@
"""cafe24_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `CAFE24_DB_URL`
(예: postgresql://cafe24_app:<pwd>@postgres-db:5432/cafe24_db)
- 스키마는 앱이 만들지 않는다. `scripts/sql/cafe24_db_init.sql` 을 superuser 가
사전 적용한다. 앱 계정(cafe24_app)은 CRUD 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
토큰 값은 이 계층에 도달하기 전 이미 Fernet 암호문이다(평문 취급 금지).
API 로그에는 토큰/시크릿을 넣지 않는다.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from datetime import date, datetime
from typing import Any, Iterator
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from . import store
logger = logging.getLogger("cafe24.db")
# save_token_row / TokenLock.save 에서 부분 갱신을 허용하는 컬럼 화이트리스트.
# 여기 없는 키는 무시한다(임의 컬럼 주입 방지).
_TOKEN_FIELDS: tuple[str, ...] = (
"access_token",
"refresh_token",
"access_token_expires_at",
"refresh_token_expires_at",
"scopes",
"last_refreshed_at",
"last_error",
"connected_by",
)
class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
def __init__(self, conn: Any, mall_id: str, row: dict[str, Any] | None):
self._conn = conn
self._mall_id = mall_id
self.row = row
def save(self, **fields: Any) -> None:
_update_token_row(self._conn, self._mall_id, fields)
def _update_token_row(conn: Any, mall_id: str, fields: dict[str, Any]) -> None:
"""UPSERT. 주어진 컬럼만 갱신한다(부분 갱신)."""
allowed = {k: v for k, v in fields.items() if k in _TOKEN_FIELDS}
if not allowed:
return
columns = list(allowed.keys())
placeholders = ", ".join(["%s"] * len(columns))
assignments = ", ".join(f"{col} = EXCLUDED.{col}" for col in columns)
conn.execute(
f"""
INSERT INTO cafe24_oauth_tokens (mall_id, {", ".join(columns)})
VALUES (%s, {placeholders})
ON CONFLICT (mall_id) DO UPDATE SET {assignments}
""",
(mall_id, *[allowed[col] for col in columns]),
)
class Cafe24Store:
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
self._pool = ConnectionPool(
conninfo=dsn,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
def close(self) -> None:
self._pool.close()
# ════════════════════════════════════════════════════════════
# OAuth 토큰 — app/integrations/cafe24/tokens.py 가 요구하는 3개 메서드
# ════════════════════════════════════════════════════════════
def get_token_row(self, mall_id: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
return conn.execute(
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s",
(mall_id,),
).fetchone()
def save_token_row(self, *, mall_id: str, **fields: Any) -> None:
with self._pool.connection() as conn:
_update_token_row(conn, mall_id, fields)
@contextmanager
def token_lock(self, mall_id: str) -> Iterator[TokenLock]:
"""토큰 행을 FOR UPDATE 로 잠근 채 작업.
web 컨테이너와 worker 컨테이너가 동시에 refresh 하는 것을 막는다
(카페24는 refresh token 을 회전시키므로 동시 갱신 시 한쪽이 무효화됨).
행이 아직 없으면 row=None 으로 넘어간다.
"""
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s FOR UPDATE",
(mall_id,),
).fetchone()
yield TokenLock(conn, mall_id, row)
def disconnect(self, mall_id: str) -> None:
"""연결 해제 — 토큰만 지운다(이력/예약은 보존)."""
with self._pool.connection() as conn:
conn.execute("DELETE FROM cafe24_oauth_tokens WHERE mall_id = %s", (mall_id,))
# ════════════════════════════════════════════════════════════
# API 호출 로그 (Cafe24Client 가 주입받아 호출)
# ⚠️ Authorization/토큰/시크릿은 절대 기록하지 않는다.
# ════════════════════════════════════════════════════════════
def log_api_call(
self,
*,
endpoint: str,
method: str,
product_no: int | None,
http_status: int | None,
result: str,
error_message: str,
duration_ms: int,
) -> None:
with self._pool.connection() as conn:
conn.execute(
"""
INSERT INTO cafe24_api_logs
(endpoint, method, product_no, http_status, result, error_message, duration_ms)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(endpoint, method, product_no, http_status, result, error_message, duration_ms),
)
def list_api_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT * FROM cafe24_api_logs
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(max(1, min(int(limit), 500)),),
).fetchall()
return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 작업 감사 로그
# ════════════════════════════════════════════════════════════
def log_audit(
self,
*,
actor: str,
action: str,
product_no: int | None = None,
revision_id: int | None = None,
schedule_id: int | None = None,
result: str = "",
detail: str = "",
) -> None:
with self._pool.connection() as conn:
self._insert_audit(
conn,
actor=actor,
action=action,
product_no=product_no,
revision_id=revision_id,
schedule_id=schedule_id,
result=result,
detail=detail,
)
@staticmethod
def _insert_audit(
conn: Any,
*,
actor: str,
action: str,
product_no: int | None = None,
revision_id: int | None = None,
schedule_id: int | None = None,
result: str = "",
detail: str = "",
) -> None:
"""호출자의 트랜잭션에 합류시키기 위해 conn 을 받는 정적 헬퍼."""
conn.execute(
"""
INSERT INTO cafe24_audit_logs
(actor, action, product_no, revision_id, schedule_id, result, detail)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(actor, action, product_no, revision_id, schedule_id, result, detail[:1000]),
)
def list_audit_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT * FROM cafe24_audit_logs
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(max(1, min(int(limit), 500)),),
).fetchall()
return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 상품 캐시
# source of truth 는 언제나 카페24다. 이 표는 목록 조회 결과를 담아두는
# 곳이며, 예약·로그 화면에서 API 호출 없이 상품명을 보여줄 때 쓴다.
# 상세설명(HTML)은 여기 넣지 않는다(cafe24_product_revisions 담당).
# ════════════════════════════════════════════════════════════
def upsert_products(self, rows: list[dict[str, Any]]) -> int:
"""정규화된 상품 dict 목록(products.normalize_product 결과)을 UPSERT."""
valid = [r for r in rows if int(r.get("product_no") or 0) > 0]
if not valid:
return 0
with self._pool.connection() as conn:
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO cafe24_products
(product_no, product_code, product_name, display, selling, last_synced_at)
VALUES (%s,%s,%s,%s,%s, now())
ON CONFLICT (product_no) DO UPDATE SET
product_code = EXCLUDED.product_code,
product_name = EXCLUDED.product_name,
display = EXCLUDED.display,
selling = EXCLUDED.selling,
last_synced_at = now()
""",
[
(
int(r["product_no"]),
str(r.get("product_code") or ""),
str(r.get("product_name") or ""),
bool(r.get("display", True)),
bool(r.get("selling", True)),
)
for r in valid
],
)
return len(valid)
def get_cached_product(self, product_no: int) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cafe24_products WHERE product_no = %s",
(int(product_no),),
).fetchone()
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
# ════════════════════════════════════════════════════════════
def add_revision(
self,
*,
product_no: int,
html_content: str,
revision_type: str,
memo: str = "",
created_by: str = "",
) -> int:
with self._pool.connection() as conn:
row = conn.execute(
"""
INSERT INTO cafe24_product_revisions
(product_no, html_content, revision_type, memo, created_by)
VALUES (%s,%s,%s,%s,%s)
RETURNING id
""",
(
int(product_no),
html_content or "",
store.normalize_revision_type(revision_type),
(memo or "")[:500],
created_by or "",
),
).fetchone()
return int(row["id"]) if row else 0
def list_revisions(self, product_no: int, *, limit: int = 20) -> list[dict[str, Any]]:
"""버전 목록. html_content 는 수 MB 일 수 있어 길이만 계산해서 준다."""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT id, product_no, revision_type, memo, created_by, created_at,
length(html_content) AS html_length
FROM cafe24_product_revisions
WHERE product_no = %s
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(int(product_no), max(1, min(int(limit), 200))),
).fetchall()
return [self._serialize(r) for r in rows]
def get_revision(self, revision_id: int) -> dict[str, Any]:
"""버전 1건 전체(HTML 포함). 복원/비교용."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cafe24_product_revisions WHERE id = %s",
(int(revision_id),),
).fetchone()
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {}
out = dict(row)
for key, value in list(out.items()):
if isinstance(value, datetime):
aware = value if value.tzinfo else value.replace(tzinfo=KST)
out[key] = aware.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(value, date):
out[key] = value.isoformat()
return out
__all__ = ["Cafe24Store", "TokenLock", "store"]
+62
View File
@@ -0,0 +1,62 @@
"""카페24 상품 상세페이지 관리 모듈 라우터.
- 경로: /cafe24
- 권한: 로그인 + `cafe24` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
카페24 연결(OAuth) 변경은 `is_admin` 만.
- 데이터: Cafe24Store (cafe24_db / PostgreSQL) 전용.
CAFE24_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 카페24 API 호출은 app/integrations/cafe24 공통 계층을 통해서만 한다.
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회
routes_system 연결(OAuth)·상태·API 로그·작업 로그
(Phase 5) routes_schedules
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from .common import base_ctx, guard
from .routes_products import products_router
from .routes_system import system_router
logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router)
router.include_router(system_router)
@router.get("/health")
def health() -> dict[str, str]:
"""포털 카드의 상태 점(dot) 용. 인증 불필요 — 상태 문자열만 반환."""
return {"status": "ok"}
@router.get("/schedules", response_class=HTMLResponse)
def schedules(request: Request) -> HTMLResponse:
"""예약관리 안내. Phase 5 에서 routes_schedules.py 로 옮긴다.
상단 탭에 링크가 있으므로 404 를 내지 않고 안내 화면을 보여준다.
"""
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="schedules")
ctx.update(
{
"page_title": "카페24 — 예약관리",
"page_subtitle": "예약 적용 · 자동 복원",
}
)
return render_template(request, "cafe24/schedules.html", ctx)
+330
View File
@@ -0,0 +1,330 @@
"""카페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에 올라간 값"으로 가정하지 않는다.
이미지 경로의 한글은 카페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, Form, 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
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 _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")),
}
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))
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,
"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 "",
"html_mobile": (
store.format_html(store.decode_html_urls(desc.mobile_description)) if desc else ""
),
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
# 상단 공통 홍보 숨김 상태(상세설명 안 CSS 블록으로 판단)
"promo_hidden": store.has_hidden_promo(desc.description) if desc else False,
"promo_hidden_mobile": store.has_hidden_promo(desc.mobile_description) if desc else False,
"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": "카페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 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")
def product_apply(
request: Request,
product_no: int,
html: str = Form(""),
base_fingerprint: str = Form(""),
memo: str = Form(""),
list_query: str = Form(""),
hide_promo: str = Form(""),
):
"""편집한 HTML 을 카페24에 즉시 적용한다.
순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
PC/모바일 미분리 상품은 모바일 필드도 같은 HTML 로 맞춘다. 분리 상품은
모바일을 건드리지 않는다(화면에 별도 반영 안내를 띄운다).
"""
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에 반영한다. 한글 이미지 경로는
# 원래의 퍼센트 인코딩으로 되돌린다.
want_hide_promo = bool(hide_promo)
submitted = store.format_html(
store.set_promo_hidden(store.encode_html_urls(html or ""), want_hide_promo)
)
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,
)
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,
)
if current.separated_mobile:
# 분리 상품은 모바일 본문을 건드리지 않는다. 다만 공통 홍보 숨김은 PC/모바일
# 양쪽에 걸어야 효과가 있으므로 그 블록만 모바일에도 맞춰준다.
mobile_target = store.set_promo_hidden(current.mobile_description, want_hide_promo)
mobile_html = mobile_target if mobile_target != current.mobile_description else None
else:
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
mobile_html = submitted
if submitted == current.description and (
mobile_html is None or 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}"
+ (", 모바일 동시 반영" if mobile_html is not None else "")
+ (", 공통홍보 숨김" if want_hide_promo else "")
+ ")",
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse(
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
status_code=303,
)
+162
View File
@@ -0,0 +1,162 @@
"""카페24 시스템 화면 — 연결(OAuth) / 연결 상태 / API 로그 / 작업 로그.
OAuth 흐름
1) 관리자가 [카페24 연결] → GET /cafe24/system/oauth/start
state 를 만들어 세션에 넣고 카페24 인증 페이지로 302.
2) 카페24가 GET /cafe24/oauth/callback?code=&state= 로 되돌려보냄.
세션 state 와 대조(CSRF 방어) 후 code → 토큰 교환, 암호화 저장.
핸들러는 `def`(동기)로 선언한다. 카페24 API·DB 호출이 블로킹이므로 FastAPI 의
스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import (
Cafe24AuthError,
Cafe24ConfigError,
Cafe24Error,
build_authorize_url,
build_cafe24_api,
exchange_code,
load_config,
new_state,
)
from .common import base_ctx, guard, render_config_needed, require_admin
logger = logging.getLogger("cafe24.system")
system_router = APIRouter()
# 세션에 state 를 담는 키
_STATE_KEY = "cafe24_oauth_state"
@system_router.get("/system", response_class=HTMLResponse)
def system_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)
try:
status = api.tokens.status()
except Cafe24Error as exc:
status = {
"connected": False,
"mall_id": api.config.mall_id,
"missing": api.config.missing,
"needs_reauth": True,
"reason": str(exc),
}
ctx = base_ctx(request, user, active_tab="system")
ctx.update(
{
"page_title": "카페24 — 시스템",
"page_subtitle": "연결 상태 · API 로그 · 작업 로그",
"status": status,
"api_version": api.config.api_version,
"scopes": api.config.scope_param,
"redirect_uri": api.config.redirect_uri,
"api_logs": st.list_api_logs(limit=50),
"audit_logs": st.list_audit_logs(limit=50),
"flash": request.query_params.get("msg", ""),
"flash_error": request.query_params.get("err", ""),
}
)
return render_template(request, "cafe24/system.html", ctx)
@system_router.get("/system/oauth/start")
def oauth_start(request: Request):
"""카페24 인증 시작 (관리자 전용)."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
config = load_config()
try:
state = new_state()
url = build_authorize_url(config, state=state)
except Cafe24ConfigError as exc:
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
request.session[_STATE_KEY] = state
return RedirectResponse(url=url, status_code=303)
@system_router.get("/oauth/callback")
def oauth_callback(request: Request):
"""카페24 콜백 — code → 토큰 교환 후 암호화 저장."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
expected = request.session.pop(_STATE_KEY, "")
received = request.query_params.get("state", "")
error = request.query_params.get("error", "")
code = request.query_params.get("code", "")
if error:
return RedirectResponse(url=f"/cafe24/system?err=카페24 인증이 취소되었습니다. ({error})", status_code=303)
if not expected or expected != received:
# state 불일치 = 위조된 콜백일 수 있다. 토큰 교환하지 않는다.
logger.warning("카페24 OAuth state 불일치 — 콜백 거부")
return RedirectResponse(
url="/cafe24/system?err=인증 state 가 일치하지 않습니다. 다시 시도하세요.",
status_code=303,
)
if not code:
return RedirectResponse(url="/cafe24/system?err=인증 코드가 없습니다.", status_code=303)
api = build_cafe24_api(st)
try:
bundle = exchange_code(api.config, code=code)
api.tokens.save_bundle(bundle, connected_by=str(user.get("email") or ""))
except (Cafe24AuthError, Cafe24ConfigError) as exc:
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_connect",
result="FAIL",
detail=str(exc),
)
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_connect",
result="SUCCESS",
detail=f"scopes={bundle.scopes}",
)
logger.info("카페24 연결 완료 (mall_id=%s)", api.config.mall_id)
return RedirectResponse(url="/cafe24/system?msg=카페24에 연결되었습니다.", status_code=303)
@system_router.post("/system/oauth/disconnect")
def oauth_disconnect(request: Request):
"""저장된 토큰 삭제 (관리자 전용). 이력/예약 데이터는 지우지 않는다."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
config = load_config()
st.disconnect(config.mall_id)
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_disconnect",
result="SUCCESS",
)
return RedirectResponse(url="/cafe24/system?msg=카페24 연결을 해제했습니다.", status_code=303)
+418
View File
@@ -0,0 +1,418 @@
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
"""
from __future__ import annotations
import hashlib
import re
from urllib.parse import quote
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
REVISION_MANUAL = "MANUAL" # 즉시 적용
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
REVISION_TYPES: tuple[str, ...] = (
REVISION_SYNC,
REVISION_DRAFT,
REVISION_BACKUP,
REVISION_MANUAL,
REVISION_SCHEDULED,
REVISION_ROLLBACK,
)
REVISION_LABELS: dict[str, str] = {
REVISION_SYNC: "현재값 동기화",
REVISION_DRAFT: "초안",
REVISION_BACKUP: "적용 직전 자동백업",
REVISION_MANUAL: "즉시 적용",
REVISION_SCHEDULED: "예약 적용",
REVISION_ROLLBACK: "복원",
}
# ── 예약 상태 (cafe24_product_schedules.status) ──
STATUS_PENDING = "PENDING"
STATUS_PROCESSING = "PROCESSING"
STATUS_SUCCESS = "SUCCESS"
STATUS_FAILED = "FAILED"
STATUS_CANCELLED = "CANCELLED"
SCHEDULE_STATUSES: tuple[str, ...] = (
STATUS_PENDING,
STATUS_PROCESSING,
STATUS_SUCCESS,
STATUS_FAILED,
STATUS_CANCELLED,
)
SCHEDULE_STATUS_LABELS: dict[str, str] = {
STATUS_PENDING: "대기",
STATUS_PROCESSING: "실행중",
STATUS_SUCCESS: "완료",
STATUS_FAILED: "실패",
STATUS_CANCELLED: "취소",
}
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
# 예약 실패 시 최대 재시도 횟수
MAX_RETRY = 3
# 종료 후 동작 (cafe24_product_schedules.end_action)
END_NONE = ""
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
END_REVISION = "revision" # 지정한 버전 적용
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
def is_editable(status: str) -> bool:
"""예약을 수정/취소할 수 있는 상태인지."""
return (status or "").strip().upper() in EDITABLE_STATUSES
def can_retry(retry_count: int) -> bool:
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
try:
return int(retry_count) < MAX_RETRY
except (TypeError, ValueError):
return False
def retry_backoff_seconds(retry_count: int) -> int:
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
table = (60, 300, 900)
try:
index = max(0, int(retry_count))
except (TypeError, ValueError):
index = 0
return table[min(index, len(table) - 1)]
def normalize_revision_type(value: str) -> str:
text = (value or "").strip().upper()
return text if text in REVISION_TYPES else REVISION_DRAFT
def parse_product_no(value: object) -> int:
"""상품번호 정규화. 잘못된 값이면 ValueError."""
try:
number = int(str(value).strip())
except (TypeError, ValueError):
raise ValueError("상품번호는 숫자여야 합니다.") from None
if number <= 0:
raise ValueError("상품번호는 1 이상이어야 합니다.")
return number
# ════════════════════════════════════════════════════════════
# 이미지 URL 의 한글 파일명 표시 (%EC%9A%A9… ↔ 용기…)
#
# 카페24는 상세페이지 HTML 안 이미지 경로를 퍼센트 인코딩해서 저장한다.
# src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
# 사람이 읽을 수 없으니 화면에서는 한글로 풀어 보여주고, 카페24에 쓸 때는 다시
# 원래 형식으로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존돼야 한다
# (encode(decode(원본)) == 원본).
#
# 안전 규칙 두 가지:
# 1) 디코딩은 **non-ASCII 바이트(%80~%FF)** 만 한다. %20·%3C·%26 같은 ASCII
# 이스케이프를 풀면 HTML 구조나 쿼리스트링이 깨진다.
# 2) 인코딩은 **URL 속성값 안의 non-ASCII** 만 한다. 본문 한글 텍스트를
# 건드리면 페이지가 깨지므로 대상 범위를 정규식으로 좁힌다.
# ════════════════════════════════════════════════════════════
# src="..." / href='...' 같은 URL 속성값
_URL_ATTR_RE = re.compile(
r"""(?P<head>\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P<q>["']))(?P<url>[^"']*)(?P=q)""",
re.IGNORECASE,
)
# CSS 의 url(...) — 인라인 <style> 안 배경 이미지
_CSS_URL_RE = re.compile(
r"""(?P<head>url\(\s*(?P<q>["']?))(?P<url>[^"')]*)(?P<tail>(?P=q)\s*\))""",
re.IGNORECASE,
)
# 연속된 %XX 중 첫 바이트가 0x80 이상인 구간(= UTF-8 멀티바이트 문자)
_NON_ASCII_PCT_RUN = re.compile(r"(?:%[89A-Fa-f][0-9A-Fa-f])+")
# 인코딩 대상에서 제외할 문자 = 모든 ASCII 출력문자.
# 결과적으로 non-ASCII 와 공백만 %XX 로 바뀐다. 괄호·밑줄·마침표는 카페24
# 원본에서도 인코딩되지 않은 채 쓰이므로 반드시 그대로 남겨야 한다.
_ASCII_SAFE = "".join(chr(code) for code in range(0x21, 0x7F))
def _decode_pct_run(match: re.Match[str]) -> str:
text = match.group(0)
try:
raw = bytes(int(text[i + 1 : i + 3], 16) for i in range(0, len(text), 3))
return raw.decode("utf-8")
except (ValueError, UnicodeDecodeError):
# UTF-8 이 아니면(EUC-KR 등) 건드리지 않는다 — 깨뜨리는 것보다 낫다.
return text
def decode_url_value(value: str) -> str:
return _NON_ASCII_PCT_RUN.sub(_decode_pct_run, value or "")
def encode_url_value(value: str) -> str:
return quote(value or "", safe=_ASCII_SAFE, encoding="utf-8")
def _map_urls(html: str, transform) -> str:
def attr(match: re.Match[str]) -> str:
return f"{match.group('head')}{transform(match.group('url'))}{match.group('q')}"
def css(match: re.Match[str]) -> str:
return f"{match.group('head')}{transform(match.group('url'))}{match.group('tail')}"
return _CSS_URL_RE.sub(css, _URL_ATTR_RE.sub(attr, html or ""))
def decode_html_urls(html: str) -> str:
"""화면 표시용 — URL 안 %XX(한글 등)를 원래 문자로 되돌린다."""
return _map_urls(html, decode_url_value)
def encode_html_urls(html: str) -> str:
"""카페24 저장용 — URL 안 non-ASCII 를 퍼센트 인코딩으로 되돌린다."""
return _map_urls(html, encode_url_value)
# ════════════════════════════════════════════════════════════
# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다.
#
# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서,
# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가
# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다.
# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다.
# <style>·<script>·<pre>·<textarea> 안은 한 글자도 건드리지 않는다.
# ════════════════════════════════════════════════════════════
# 앞뒤 공백이 렌더링에 영향을 주지 않는 구조 태그만 넣는다.
_BLOCK_TAGS = frozenset(
"""html head body div p table thead tbody tfoot tr td th caption colgroup col
ul ol li dl dt dd section article header footer nav aside main
figure figcaption form fieldset legend h1 h2 h3 h4 h5 h6 hr center blockquote
style script iframe noscript""".split()
)
# 안쪽을 원문 그대로 보존할 태그
_RAW_TAGS = frozenset({"style", "script", "pre", "textarea"})
# 닫는 태그가 없는 태그
_VOID_TAGS = frozenset(
"area base br col embed hr img input link meta param source track wbr".split()
)
# 들여쓰기가 무한히 깊어지지 않게 (닫는 태그를 생략한 HTML 이 흔하다)
_MAX_INDENT = 12
_TOKEN_RE = re.compile(
r"(?P<comment><!--.*?-->)"
r"|(?P<cdata><!\[CDATA\[.*?\]\]>)"
r"|(?P<decl><![^>]*>)"
r"|(?P<tag><(?P<slash>/?)\s*(?P<name>[a-zA-Z][\w:.-]*)"
r"(?P<attrs>(?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>)",
re.DOTALL,
)
def format_html(html: str, *, indent: str = " ") -> str:
"""상세페이지 HTML 을 사람이 읽기 좋게 정리한다.
실패하면 원본을 그대로 돌려준다 — 정리보다 안 깨지는 게 중요하다.
같은 값을 두 번 넣어도 결과가 같다(멱등).
"""
source = html or ""
if not source.strip():
return source
try:
return _format_html(source, indent)
except Exception: # noqa: BLE001 — 어떤 이유로든 원본을 지키는 쪽을 택한다.
return source
def _format_html(source: str, indent: str) -> str:
lines: list[str] = []
buffer = ""
depth = 0
def pad(level: int) -> str:
return indent * min(max(level, 0), _MAX_INDENT)
def flush() -> None:
"""모아둔 인라인/텍스트를 내보낸다.
원문에 이미 있던 줄바꿈은 **그대로 살린다.** 이미지가 한 줄에 하나씩 적혀
있으면 그 모양이 저자의 의도이고, 한 줄로 합치면 오히려 읽기 어려워진다.
각 줄마다 현재 깊이로 들여쓴다(줄 앞 공백은 렌더링에 영향이 없다).
빈 줄은 연속 한 개까지만 남겨 구획을 유지한다.
"""
nonlocal buffer
# 양 끝 공백을 함께 제거한다. `"\n"` 만 벗기면 끝에 남은 `"\n "` 조각이
# 빈 줄로 바뀌어 실행마다 빈 줄이 하나씩 늘어난다(멱등 깨짐).
# 블록 태그 경계의 공백은 렌더링에 영향이 없으므로 제거해도 안전하다.
text = buffer.strip()
buffer = ""
if not text:
return
for raw_line in text.split("\n"):
line = raw_line.strip()
if not line:
# 문서 맨 앞이나 빈 줄 뒤에는 빈 줄을 더하지 않는다.
if lines and lines[-1] != "":
lines.append("")
continue
lines.append(pad(depth) + line)
position = 0
while True:
match = _TOKEN_RE.search(source, position)
if match is None:
buffer += source[position:]
break
buffer += source[position : match.start()]
position = match.end()
raw = match.group(0)
# 주석·DOCTYPE 등은 흐름에 그대로 둔다.
# 상세페이지에는 `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤 요소를
# 설명하는 주석이 많다. 줄을 강제로 나누면 라벨과 대상이 떨어져 오히려
# 읽기 나빠진다. 원문에서 줄이 나뉘어 있었다면 flush 가 그 줄바꿈을 살린다.
if match.group("comment") or match.group("cdata") or match.group("decl"):
buffer += raw
continue
name = (match.group("name") or "").lower()
closing = bool(match.group("slash"))
self_closed = (match.group("attrs") or "").rstrip().endswith("/")
# <style>/<script>/<pre>/<textarea> 안은 원문 유지
if name in _RAW_TAGS and not closing:
end = re.compile(r"</\s*%s\s*>" % re.escape(name), re.IGNORECASE).search(
source, position
)
inner = source[position : end.start()] if end else source[position:]
flush()
lines.append(pad(depth) + raw)
# 앞뒤 빈 줄은 버린다 — 남기면 매번 실행할 때마다 한 줄씩 늘어난다(멱등 깨짐).
body = inner.strip("\n")
if body:
for line in body.split("\n"):
lines.append(line.rstrip())
if end:
lines.append(pad(depth) + end.group(0))
position = end.end()
else:
position = len(source)
continue
# 인라인 태그와 텍스트는 줄을 나누지 않는다 (공백이 생기면 렌더링이 바뀐다)
if name not in _BLOCK_TAGS:
buffer += raw
continue
if closing:
flush()
depth -= 1
lines.append(pad(depth) + raw)
else:
flush()
lines.append(pad(depth) + raw)
if name not in _VOID_TAGS and not self_closed:
depth += 1
flush()
return "\n".join(_collapse_short_blocks(lines))
# 짧은 블록을 한 줄로 되돌릴 때 쓰는 패턴
_OPEN_TAG_LINE = re.compile(
r"^(?P<pad>\s*)<(?P<name>[a-zA-Z][\w:.-]*)(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>$"
)
_BLOCK_TAG_IN_TEXT = re.compile(
r"</?(?:%s)\b" % "|".join(sorted(_BLOCK_TAGS)), re.IGNORECASE
)
# 한 줄로 합칠 최대 길이
_COLLAPSE_WIDTH = 120
def _collapse_short_blocks(lines: list[str]) -> list[str]:
"""`<td>\n 1\n</td>` 처럼 내용이 한 줄뿐인 짧은 블록은 한 줄로 되돌린다.
보기 좋게 하려는 것이며, 합치는 규칙이 결정적이라 멱등성은 유지된다.
"""
out: list[str] = []
index = 0
while index < len(lines):
opening = _OPEN_TAG_LINE.match(lines[index])
if opening and index + 2 < len(lines):
name = opening.group("name").lower()
middle = lines[index + 1].strip()
closing = lines[index + 2].strip()
merged = lines[index] + middle + closing
if (
name not in _VOID_TAGS
and name not in _RAW_TAGS
and closing.lower() == f"</{name}>"
and middle
and not _BLOCK_TAG_IN_TEXT.search(middle)
and len(merged) <= _COLLAPSE_WIDTH
):
out.append(merged)
index += 3
continue
out.append(lines[index])
index += 1
return out
# ════════════════════════════════════════════════════════════
# 상단 공통 홍보 숨기기
#
# 스킨(detail.html)에는 공통 홍보를 지울 상품번호 목록이 박혀 있다.
# const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
# 그런데 카페24 Admin API 는 **스킨 파일을 읽거나 쓸 수 없다**(테마는 조회만).
# 그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다 — 상세설명은 우리가 쓸 수 있고,
# 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
#
# id 를 붙여 우리가 넣은 블록임을 표시한다. 사람이 쓴 <style> 은 건드리지 않는다.
# ════════════════════════════════════════════════════════════
HIDE_PROMO_ID = "cf24-hide-common-promo"
HIDE_PROMO_BLOCK = (
f'<style id="{HIDE_PROMO_ID}">/* DBX ERP: 상단 공통 홍보 숨김 */\n'
".edb-img-tag-w{display:none !important}\n"
"</style>"
)
_HIDE_PROMO_RE = re.compile(
r"[ \t]*<style[^>]*\bid\s*=\s*[\"']%s[\"'][^>]*>.*?</style>\s*" % re.escape(HIDE_PROMO_ID),
re.IGNORECASE | re.DOTALL,
)
def has_hidden_promo(html: str) -> bool:
"""이 상품의 상세설명에 공통 홍보 숨김 블록이 들어 있는가."""
return bool(_HIDE_PROMO_RE.search(html or ""))
def set_promo_hidden(html: str, hidden: bool) -> str:
"""숨김 블록을 넣거나 뺀다. 여러 번 호출해도 결과가 같다(멱등).
넣을 때는 맨 앞에 둔다 — 찾기 쉽고, 상세설명 어디에 있어도 CSS 효과는 같다.
"""
stripped = _HIDE_PROMO_RE.sub("", html or "")
if not hidden:
return stripped
if not stripped.strip():
return HIDE_PROMO_BLOCK
return HIDE_PROMO_BLOCK + "\n" + stripped.lstrip("\n")
def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
"""
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
@@ -0,0 +1,128 @@
{# 오른쪽 편집기 조각.
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
{% if editor_error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ editor_error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
<div class="cf24-editor-head">
<div>
<h3 class="cf24-editor-title">{{ info.product_name or '상품' }}</h3>
<p class="cf24-editor-sub">
상품번호 {{ product_no }}
{% if info.product_code %}· <code>{{ info.product_code }}</code>{% endif %}
{% if info.price %}· {{ info.price }}{% endif %}
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
</p>
</div>
<div class="cf24-editor-badges">
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
</div>
</div>
{% if desc %}
<p class="cf24-note">
{% if desc.separated_mobile %}
<strong>PC/모바일 분리 사용 상품입니다.</strong> 아래 적용은 PC 만 바꿉니다 —
모바일({{ desc.mobile_description | length }}자)은 카페24 관리자에서 따로 반영해야 합니다.
{% else %}
모바일은 PC와 동일 설정이라 적용 시 <strong>함께 반영</strong>됩니다.
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">현재 PC/모바일 내용이 다릅니다.</span>{% endif %}
<br />
소스는 <strong>태그마다 줄을 나눠 정리</strong>해서 보여주며, <strong>적용하면 정리된 소스가
그대로 카페24에 저장</strong>됩니다. 줄바꿈·들여쓰기만 바뀌고 태그 구조는 그대로이며,
이미지가 벌어지지 않도록 <code>img</code>·<code>span</code> 같은 인라인 요소와
<code>&lt;style&gt;</code> 안쪽은 건드리지 않습니다.
<br />
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 로 저장되어 있습니다.
여기서는 한글로 보여주고, 적용할 때 원래 형식으로 되돌립니다.
</p>
<form class="cf24-editor-form" method="post"
action="/cafe24/products/{{ product_no }}/apply"
data-confirm="카페24 쇼핑몰에 바로 반영됩니다. 적용할까요?&#10;&#10;직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
<input type="hidden" name="list_query" value="{{ list_query }}" />
<div class="cf24-editor-bar">
<span class="cf24-muted">
PC 상세설명 HTML · {{ desc.description | length }}자
<label class="cf24-check-inline" title="상세페이지 상단의 공통 홍보 영역(.edb-img-tag-w)을 이 상품에서만 숨깁니다. PC·모바일 모두 적용됩니다.">
<input type="checkbox" name="hide_promo" value="1"
{% if promo_hidden %}checked{% endif %} />
상단 공통 홍보 숨기기
{% if promo_hidden != promo_hidden_mobile %}
<span class="cf24-warn">(PC/모바일 상태 불일치 — 적용하면 맞춰집니다)</span>
{% endif %}
</label>
</span>
<span class="cf24-editor-bar-right">
<input class="cf24-memo" type="text" name="memo" maxlength="200"
placeholder="변경 메모 (버전 이력에 남습니다)" />
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">복사</button>
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
</span>
</div>
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다.
<pre> 가 크기를 정하고 <textarea> 는 inset:0 으로 그 위를 정확히 덮는다.
줄바꿈을 하지 않고(wrap=off) 가로로 스크롤하므로 줄 번호가 항상 맞는다.
두 요소의 폰트·여백이 다르면 글자가 어긋난다 — CSS 에서 함께 관리한다. #}
<div class="cf24-code" id="cf24-code-pc">
<div class="cf24-gutter" aria-hidden="true"><div class="cf24-gutter-inner" id="cf24-gutter-pc"></div></div>
<div class="cf24-code-body">
<pre class="cf24-code-hl" id="cf24-hl-pc" aria-hidden="true"></pre>
<textarea id="cf24-html-pc" class="cf24-code-input" name="html" wrap="off"
spellcheck="false" autocapitalize="off" autocorrect="off">{{ html_pc }}</textarea>
</div>
</div>
</form>
{% if desc.separated_mobile or desc.mobile_differs %}
<details class="cf24-details">
<summary>모바일 상세설명 HTML 보기 (읽기 전용 · {{ desc.mobile_description | length }}자)</summary>
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly
spellcheck="false">{{ html_mobile }}</textarea>
<div class="cf24-actions">
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">복사</button>
</div>
</details>
{% endif %}
<details class="cf24-details">
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
{% if revisions %}
<div class="cf24-scroll">
<table class="erp-table cf24-compact">
<thead>
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
</thead>
<tbody>
{% for rev in revisions %}
<tr>
<td class="cf24-nowrap">{{ rev.created_at }}</td>
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
<td>{{ rev.memo or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="cf24-muted">버전 선택 복원은 Phase 6 에서 붙습니다. 내용은 모두 보관됩니다.</p>
{% else %}
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
{% endif %}
</details>
{% endif %}
@@ -0,0 +1,9 @@
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #}
<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 %}"
href="/cafe24/">상품관리</a>
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/schedules">예약관리</a>
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/system">시스템</a>
</div>
@@ -0,0 +1,326 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% set qs = (list_query ~ '&') if list_query else '' %}
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
{% if error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
<div class="cf24-split">
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
<aside class="erp-card cf24-pane cf24-pane-list">
<form class="cf24-filters" method="get" action="/cafe24/" id="cf24-filter-form">
{% if selected %}<input type="hidden" name="selected" value="{{ selected }}" />{% endif %}
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
placeholder="상품명 검색" />
<div class="cf24-checks">
<label><input type="checkbox" name="display" value="1"
{% if only_display %}checked{% endif %} /> 진열중</label>
<label><input type="checkbox" name="selling" value="1"
{% if only_selling %}checked{% endif %} /> 판매중</label>
</div>
<div class="cf24-list-count">
{{ shown }}건{% if shown != total %} / 전체 {{ total }}건{% endif %}
{% if truncated %}<span class="cf24-warn">(상한 도달)</span>{% endif %}
</div>
</form>
<div class="cf24-list-scroll">
<table class="erp-table cf24-list-table" id="cf24-list">
<thead>
<tr>
<th class="cf24-col-no" data-sort-key="no" data-sort-type="num">번호</th>
<th class="cf24-col-name" data-sort-key="name" data-sort-type="text">상품명</th>
<th class="cf24-col-flag" data-sort-key="display" data-sort-type="num">진열</th>
<th class="cf24-col-flag" data-sort-key="selling" data-sort-type="num">판매</th>
<th class="cf24-col-date" data-sort-key="updated" data-sort-type="text">수정</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="cf24-row {% if selected == r.product_no %}is-active{% endif %}"
data-no="{{ r.product_no }}"
data-name="{{ r.product_name }}"
data-display="{{ 1 if r.display else 0 }}"
data-selling="{{ 1 if r.selling else 0 }}"
data-updated="{{ r.updated_date }}">
<td class="cf24-col-no">{{ r.product_no }}</td>
<td class="cf24-col-name" title="{{ r.product_name }}">
<a href="/cafe24/?{{ qs }}selected={{ r.product_no }}">{{ r.product_name }}</a>
</td>
<td class="cf24-col-flag">
{% if r.display %}<span class="cf24-dot cf24-dot-on" title="진열중"></span>
{% else %}<span class="cf24-dot" title="미진열"></span>{% endif %}
</td>
<td class="cf24-col-flag">
{% if r.selling %}<span class="cf24-dot cf24-dot-on" title="판매중"></span>
{% else %}<span class="cf24-dot" title="판매중지"></span>{% endif %}
</td>
{# 연도는 생략(월-일 시:분). 전체 값은 title 로 확인 #}
<td class="cf24-col-date" title="{{ r.updated_date }}">{{ r.updated_date[5:16] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not rows and not error %}
<p class="cf24-muted" style="padding:12px;">조건에 맞는 상품이 없습니다.</p>
{% endif %}
</div>
</aside>
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
{% if selected %}
{% include "cafe24/_editor.html" %}
{% else %}
<div class="cf24-empty-pane">
<h3>왼쪽에서 상품을 선택하세요.</h3>
<p class="cf24-muted">
선택한 상품의 상세페이지 HTML 을 여기서 바로 편집하고 카페24에 적용할 수 있습니다.<br />
적용 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.
</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
var pane = document.getElementById("cf24-editor-pane");
var listQuery = {{ list_query | tojson }};
var dirty = false;
/* ── 문법 강조 ──────────────────────────────────────────────
색칠된 <pre> 를 투명한 <textarea> 뒤에 겹쳐 놓는 방식. 외부 라이브러리를
쓰지 않는다(자체 호스팅 원칙). 태그·속성이름·속성값·주석·기호를 구분한다. */
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
// 이 길이를 넘으면 강조를 끈다 — 타이핑마다 다시 칠하면 느려진다.
var HL_LIMIT = 200000;
function esc(text) {
return text.replace(/[&<>]/g, function (c) {
return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;";
});
}
function paintAttrs(text) {
return text.replace(ATTR_RE, function (whole, name, eq, val) {
if (!name) return esc(whole);
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
return out;
});
}
function paintHtml(src) {
var out = "", last = 0, m;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(src)) !== null) {
out += esc(src.slice(last, m.index));
last = TOKEN_RE.lastIndex;
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
paintAttrs(m[5]) +
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
}
return out + esc(src.slice(last));
}
function setupCodeEditor() {
var ta = document.getElementById("cf24-html-pc");
var hl = document.getElementById("cf24-hl-pc");
var gutter = document.getElementById("cf24-gutter-pc");
if (!ta || !hl) return;
var timer = null;
function renderGutter(count) {
if (!gutter || gutter.dataset.lines === String(count)) return;
var out = "";
for (var i = 1; i <= count; i++) out += i + "\n";
gutter.textContent = out;
gutter.dataset.lines = String(count);
}
// 스크롤 주체는 textarea 다. 색칠 층과 줄 번호를 같은 양만큼 이동시켜
// 글자 위치를 맞춘다(크기를 계산해 맞추는 방식은 긴 줄에서 어긋났다).
function sync() {
var x = ta.scrollLeft, y = ta.scrollTop;
hl.style.transform = "translate(" + -x + "px," + -y + "px)";
if (gutter) gutter.style.transform = "translateY(" + -y + "px)";
}
function repaint() {
// 마지막 줄이 잘리지 않게 개행을 하나 덧붙인다(<pre> 특성).
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
else hl.innerHTML = paintHtml(ta.value) + "\n";
renderGutter(ta.value.split("\n").length);
sync();
}
function refresh() {
// 짧은 소스는 바로 칠해야 줄 번호가 즉시 따라온다.
if (ta.value.length < 50000) { repaint(); return; }
clearTimeout(timer);
timer = setTimeout(repaint, 80);
}
ta.addEventListener("scroll", sync);
ta.addEventListener("input", function () { dirty = true; refresh(); });
// Tab 은 포커스 이동이 아니라 들여쓰기로 쓴다.
ta.addEventListener("keydown", function (e) {
if (e.key !== "Tab") return;
e.preventDefault();
var start = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 2;
dirty = true;
refresh();
});
repaint();
}
// ── 편집기 조각을 새로 끼워 넣은 뒤 다시 연결 ──
window.cf24BindEditor = function () {
dirty = false;
setupCodeEditor();
pane.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = document.getElementById(btn.dataset.copy);
if (!box) return;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사됨";
setTimeout(function () { btn.textContent = old; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
} else {
box.select();
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
}
});
});
var form = pane.querySelector(".cf24-editor-form");
if (form) {
form.addEventListener("submit", function (e) {
if (!window.confirm(form.dataset.confirm)) { e.preventDefault(); return; }
dirty = false;
});
}
};
function confirmLeave() {
return !dirty || window.confirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
}
// ── 목록 클릭 → 오른쪽만 교체 ──
function select(no, push) {
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
fetch(url, { credentials: "same-origin" })
.then(function (res) {
if (res.redirected) { window.location.href = res.url; return null; }
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (htmlText) {
if (htmlText === null) return;
pane.innerHTML = htmlText;
window.cf24BindEditor();
pane.scrollTop = 0;
if (push) {
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
history.pushState({ no: no }, "", target);
}
})
.catch(function () {
// 조각 로드가 실패하면 평범한 페이지 이동으로 대체한다.
window.location.href = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
});
}
document.querySelectorAll("#cf24-list tbody tr.cf24-row").forEach(function (tr) {
tr.addEventListener("click", function (e) {
if (e.target.tagName === "A") e.preventDefault();
if (!confirmLeave()) return;
document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) {
el.classList.remove("is-active");
});
tr.classList.add("is-active");
select(tr.dataset.no, true);
});
});
window.addEventListener("popstate", function () {
window.location.reload();
});
// ── 필터 체크박스는 즉시 적용 ──
var filterForm = document.getElementById("cf24-filter-form");
filterForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) {
cb.addEventListener("change", function () {
if (confirmLeave()) filterForm.submit();
});
});
// ── 제목행 클릭 정렬(오름/내림 토글) ──
var table = document.getElementById("cf24-list");
var tbody = table.querySelector("tbody");
var sortKey = null, sortAsc = true;
table.querySelectorAll("th[data-sort-key]").forEach(function (th) {
th.classList.add("cf24-sortable");
th.addEventListener("click", function () {
var key = th.dataset.sortKey;
sortAsc = key === sortKey ? !sortAsc : true;
sortKey = key;
table.querySelectorAll("th[data-sort-key]").forEach(function (other) {
other.classList.remove("is-asc", "is-desc");
});
th.classList.add(sortAsc ? "is-asc" : "is-desc");
var numeric = th.dataset.sortType === "num";
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr.cf24-row"));
rows.sort(function (a, b) {
var x = a.dataset[key] || "", y = b.dataset[key] || "";
var cmp = numeric
? (parseFloat(x) || 0) - (parseFloat(y) || 0)
: x.localeCompare(y, "ko");
return sortAsc ? cmp : -cmp;
});
rows.forEach(function (tr) { tbody.appendChild(tr); });
});
});
window.addEventListener("beforeunload", function (e) {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
});
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
})();
</script>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
<div class="erp-card cf24-empty">
<h3>예약관리는 Phase 5 에서 열립니다.</h3>
<p>
지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
예약은 <code>dbx-cafe24-worker</code> 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
</p>
<p>
지금은 <a href="/cafe24/">상품 목록</a>에서 현재 상세페이지 HTML 을 확인할 수 있습니다.
</p>
</div>
{% endblock %}
@@ -0,0 +1,137 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
{# ── 연결 상태 ───────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>카페24 연결</h3>
{% if status.connected %}
<span class="erp-badge cf24-badge-ok">연결됨</span>
{% else %}
<span class="erp-badge cf24-badge-off">연결 안 됨</span>
{% endif %}
</div>
{% if status.missing %}
<div class="cf24-flash cf24-flash-err">
다음 환경변수가 설정되지 않았습니다:
<code>{{ status.missing | join(', ') }}</code><br />
<code>.env</code> 에 추가한 뒤 컨테이너를 재기동하세요.
</div>
{% endif %}
{% if status.reason %}<p class="cf24-muted">{{ status.reason }}</p>{% endif %}
<table class="erp-table cf24-kv">
<tbody>
<tr><th>쇼핑몰 ID</th><td>{{ status.mall_id or '—' }}</td></tr>
<tr><th>API 버전</th><td>{{ api_version }}</td></tr>
<tr><th>요청 권한(scope)</th><td><code>{{ scopes }}</code></td></tr>
<tr><th>Redirect URI</th><td><code>{{ redirect_uri or '—' }}</code></td></tr>
<tr>
<th>승인된 권한</th>
<td>{% if status.scopes %}<code>{{ status.scopes }}</code>{% else %}—{% endif %}</td>
</tr>
<tr>
<th>Access Token 만료</th>
<td>
{{ status.access_token_expires_at or '—' }}
{% if status.access_expired %}<span class="cf24-muted">(만료 — 다음 호출 시 자동 갱신)</span>{% endif %}
</td>
</tr>
<tr><th>Refresh Token 만료</th><td>{{ status.refresh_token_expires_at or '—' }}</td></tr>
<tr><th>마지막 갱신</th><td>{{ status.last_refreshed_at or '—' }}</td></tr>
<tr><th>연결한 사람</th><td>{{ status.connected_by or '—' }}</td></tr>
{% if status.last_error %}
<tr><th>마지막 오류</th><td class="cf24-err">{{ status.last_error }}</td></tr>
{% endif %}
</tbody>
</table>
{% if is_admin %}
<div class="cf24-actions">
<a class="erp-btn erp-btn-primary" href="/cafe24/system/oauth/start">
{% if status.connected %}카페24 재연결{% else %}카페24 연결{% endif %}
</a>
{% if status.connected or status.needs_reauth %}
<form method="post" action="/cafe24/system/oauth/disconnect" style="display:inline;"
onsubmit="return confirm('저장된 카페24 토큰을 삭제합니다. 계속할까요?\n(변경 이력·예약 데이터는 지워지지 않습니다)');">
<button type="submit" class="erp-btn erp-btn-outline">연결 해제</button>
</form>
{% endif %}
</div>
{% else %}
<p class="cf24-muted">카페24 연결 변경은 관리자만 할 수 있습니다.</p>
{% endif %}
</div>
{# ── 작업 로그 ───────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head"><h3>작업 로그</h3><span class="cf24-muted">최근 50건</span></div>
{% if audit_logs %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr><th>시각</th><th>작업자</th><th>작업</th><th>상품</th><th>결과</th><th>내용</th></tr>
</thead>
<tbody>
{% for log in audit_logs %}
<tr>
<td class="cf24-nowrap">{{ log.created_at }}</td>
<td>{{ log.actor or '—' }}</td>
<td>{{ log.action }}</td>
<td>{{ log.product_no or '—' }}</td>
<td class="{% if log.result == 'FAIL' %}cf24-err{% endif %}">{{ log.result or '—' }}</td>
<td>{{ log.detail or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cf24-muted">아직 기록된 작업이 없습니다.</p>
{% endif %}
</div>
{# ── API 로그 ────────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>카페24 API 로그</h3>
<span class="cf24-muted">최근 50건 · 토큰/시크릿은 기록하지 않습니다</span>
</div>
{% if api_logs %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr><th>시각</th><th>메서드</th><th>엔드포인트</th><th>상품</th><th>상태</th><th>결과</th><th>소요</th><th>오류</th></tr>
</thead>
<tbody>
{% for log in api_logs %}
<tr>
<td class="cf24-nowrap">{{ log.created_at }}</td>
<td>{{ log.method }}</td>
<td><code>{{ log.endpoint }}</code></td>
<td>{{ log.product_no or '—' }}</td>
<td>{{ log.http_status or '—' }}</td>
<td class="{% if log.result != 'SUCCESS' %}cf24-err{% endif %}">{{ log.result }}</td>
<td class="cf24-nowrap">{{ log.duration_ms }}ms</td>
<td>{{ log.error_message or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cf24-muted">아직 API 호출 기록이 없습니다.</p>
{% endif %}
</div>
{% endblock %}
+597
View File
@@ -0,0 +1,597 @@
"""카페24 모듈 순수 로직 + 토큰/암호화 테스트.
DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입).
python -m app.modules.cafe24.tests.test_cafe24
또는 pytest 로 실행 가능.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from datetime import timedelta
from app.integrations.cafe24 import config as cfgmod
from app.integrations.cafe24 import crypto, oauth, products, tokens
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
from app.modules.cafe24 import store
from app.timezone import now_kst
SECRET = "unit-test-secret"
_TEST_ENV = {
"CAFE24_MALL_ID": "testmall",
"CAFE24_CLIENT_ID": "cid",
"CAFE24_CLIENT_SECRET": "csecret",
"CAFE24_REDIRECT_URI": "http://localhost:8080/cafe24/oauth/callback",
"CAFE24_TOKEN_SECRET": SECRET,
}
def _config():
"""환경변수에 의존하지 않도록 테스트용 값을 주입해 설정을 만든다."""
saved = {k: os.environ.get(k) for k in _TEST_ENV}
os.environ.update(_TEST_ENV)
try:
return cfgmod.load_config()
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
class _FakeRepo:
"""Cafe24Store 의 토큰 3개 메서드만 흉내낸다."""
def __init__(self, row=None):
self.row = row
self.saves: list[dict] = []
def get_token_row(self, mall_id):
return self.row
def save_token_row(self, *, mall_id, **fields):
self.saves.append(fields)
if self.row is None:
self.row = {"mall_id": mall_id}
self.row.update(fields)
@contextmanager
def token_lock(self, mall_id):
outer = self
class Handle:
row = outer.row
def save(self, **fields):
outer.save_token_row(mall_id=mall_id, **fields)
yield Handle()
def _row(**overrides):
row = {
"mall_id": "testmall",
"access_token": crypto.encrypt("AT", secret=SECRET),
"refresh_token": crypto.encrypt("RT", secret=SECRET),
"access_token_expires_at": now_kst() + timedelta(hours=1),
"refresh_token_expires_at": now_kst() + timedelta(days=13),
"scopes": "mall.read_product,mall.write_product",
"last_refreshed_at": now_kst(),
"last_error": "",
"connected_by": "king@dbxcorp.co.kr",
}
row.update(overrides)
return row
# ════════════════════════════════════════════════════════════
# 암호화
# ════════════════════════════════════════════════════════════
def test_crypto_roundtrip():
token = "ACCESS-TOKEN-한글-123"
encrypted = crypto.encrypt(token, secret=SECRET)
assert encrypted != token and token not in encrypted
assert crypto.decrypt(encrypted, secret=SECRET) == token
def test_crypto_empty_passthrough():
assert crypto.encrypt("", secret=SECRET) == ""
assert crypto.decrypt("", secret=SECRET) == ""
def test_crypto_wrong_secret_raises():
encrypted = crypto.encrypt("AT", secret=SECRET)
try:
crypto.decrypt(encrypted, secret="다른키")
except Cafe24ConfigError:
return
raise AssertionError("키가 바뀌면 Cafe24ConfigError 가 나야 한다")
def test_crypto_requires_secret():
try:
crypto.encrypt("x", secret="")
except Cafe24ConfigError:
return
raise AssertionError("CAFE24_TOKEN_SECRET 없으면 예외여야 한다")
# ════════════════════════════════════════════════════════════
# 설정 / 인증 URL
# ════════════════════════════════════════════════════════════
def test_config_basics():
config = _config()
assert config.configured
assert config.missing == []
assert config.api_base == "https://testmall.cafe24api.com/api/v2"
# 요청 scope 는 DEFAULT_SCOPES 한 곳에서 나온다(authorize 와 클라이언트가
# 어긋나지 않게). 늘어날 때 이 테스트도 함께 갱신할 것.
assert config.scope_param == ",".join(cfgmod.DEFAULT_SCOPES)
assert "mall.read_product" in config.scope_param
assert "mall.write_product" in config.scope_param
def test_authorize_url_has_state_and_no_secret():
url = oauth.build_authorize_url(_config(), state="STATE123")
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
assert "state=STATE123" in url
# client_secret 은 authorize 단계에 절대 실리면 안 된다.
assert "csecret" not in url
# ════════════════════════════════════════════════════════════
# 토큰 상태 / 자동 갱신
# ════════════════════════════════════════════════════════════
def test_status_without_token():
status = tokens.TokenService(_FakeRepo(None), _config()).status()
assert status["connected"] is False
assert status["needs_reauth"] is True
def test_status_never_leaks_token_values():
service = tokens.TokenService(_FakeRepo(_row()), _config())
status = service.status()
assert status["connected"] is True
assert "AT" not in str(status) and "RT" not in str(status)
def test_valid_token_returned_without_refresh():
service = tokens.TokenService(_FakeRepo(_row()), _config())
assert service.get_access_token() == "AT"
def test_expired_refresh_token_needs_reauth():
row = _row(refresh_token_expires_at=now_kst() - timedelta(days=1))
assert tokens.TokenService(_FakeRepo(row), _config()).status()["needs_reauth"] is True
def test_expired_access_token_triggers_refresh():
"""만료된 access token 은 refresh 후 새 값을 돌려주고, 저장은 암호문으로 한다."""
repo = _FakeRepo(_row(access_token_expires_at=now_kst() - timedelta(minutes=5)))
service = tokens.TokenService(repo, _config())
seen: list[str] = []
def fake_refresh(config, *, refresh_token):
seen.append(refresh_token)
return oauth.TokenBundle(
access_token="NEW-AT",
refresh_token="NEW-RT",
access_token_expires_at=now_kst() + timedelta(hours=2),
refresh_token_expires_at=now_kst() + timedelta(days=14),
scopes="mall.read_product,mall.write_product",
)
original = tokens.refresh_tokens
tokens.refresh_tokens = fake_refresh
try:
assert service.get_access_token() == "NEW-AT"
finally:
tokens.refresh_tokens = original
assert seen == ["RT"] # 복호화된 refresh token 이 전달돼야 한다
saved = repo.saves[-1]
assert saved["access_token"] != "NEW-AT" # 평문 저장 금지
assert crypto.decrypt(saved["access_token"], secret=SECRET) == "NEW-AT"
assert saved["last_error"] == ""
def test_dead_refresh_token_raises_auth_error():
row = _row(
access_token_expires_at=now_kst() - timedelta(minutes=1),
refresh_token_expires_at=now_kst() - timedelta(days=1),
)
service = tokens.TokenService(_FakeRepo(row), _config())
try:
service.get_access_token()
except Cafe24AuthError as exc:
assert exc.needs_reauth is True
return
raise AssertionError("refresh token 만료 시 Cafe24AuthError 여야 한다")
# ════════════════════════════════════════════════════════════
# store.py 순수 로직
# ════════════════════════════════════════════════════════════
def test_schedule_editable_only_when_pending():
assert store.is_editable("PENDING") is True
for locked in ("PROCESSING", "SUCCESS", "FAILED", "CANCELLED"):
assert store.is_editable(locked) is False, locked
def test_retry_budget_and_backoff():
assert all(store.can_retry(i) for i in range(store.MAX_RETRY))
assert store.can_retry(store.MAX_RETRY) is False
# 무한 재시도 방지 — 간격은 증가하되 상한이 있다.
assert store.retry_backoff_seconds(0) < store.retry_backoff_seconds(1)
assert store.retry_backoff_seconds(99) == store.retry_backoff_seconds(2)
def test_normalize_revision_type():
assert store.normalize_revision_type("backup") == store.REVISION_BACKUP
assert store.normalize_revision_type("nope") == store.REVISION_DRAFT
def test_parse_product_no():
assert store.parse_product_no(" 123 ") == 123
for bad in ("abc", "0", "-3", None, ""):
try:
store.parse_product_no(bad)
except ValueError:
continue
raise AssertionError(f"{bad!r} 는 거부해야 한다")
# ════════════════════════════════════════════════════════════
# 상품 엔드포인트 래퍼
# 실제 쇼핑몰 확인 결과 /admin/products/{no}/description 은 존재하지 않는다
# (`No API found.`). 상세설명은 상품 리소스의 필드다 — 경로가 되돌아가지 않게
# 여기서 고정한다.
# ════════════════════════════════════════════════════════════
class _FakeClient:
"""Cafe24Client 의 get/put 만 흉내내고 호출을 기록한다."""
def __init__(self, payload=None):
self.payload = payload or {}
self.calls: list[dict] = []
def get(self, path, *, params=None, json=None, product_no=None):
self.calls.append({"method": "GET", "path": path, "params": params})
return self.payload
def put(self, path, *, params=None, json=None, product_no=None):
self.calls.append({"method": "PUT", "path": path, "json": json})
return self.payload
_PRODUCT = {
"product_no": 131,
"product_code": "P000000B",
"product_name": "빠져락 1개(사은품)",
"display": "T",
"selling": "F",
"description": "<p>PC</p>",
"mobile_description": "<p>PC</p>",
"separated_mobile_description": "F",
}
def test_descriptions_from_product():
desc = products.descriptions_from_product(_PRODUCT)
assert desc.product_no == 131
assert desc.description == "<p>PC</p>"
assert desc.separated_mobile is False
assert desc.mobile_differs is False
def test_descriptions_separated_mobile_and_diff():
desc = products.descriptions_from_product(
{**_PRODUCT, "separated_mobile_description": "T", "mobile_description": "<p>MO</p>"}
)
assert desc.separated_mobile is True
assert desc.mobile_differs is True
def test_fetch_descriptions_uses_product_resource():
client = _FakeClient({"product": _PRODUCT})
desc = products.fetch_descriptions(client, 131)
assert desc.description == "<p>PC</p>"
paths = [c["path"] for c in client.calls]
assert paths == ["/admin/products/131"], paths
assert not any(p.endswith("/description") for p in paths)
def test_update_descriptions_payload():
client = _FakeClient({"product": _PRODUCT})
products.update_descriptions(client, 131, description="<p>NEW</p>")
call = client.calls[0]
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
# 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다.
assert call["json"] == {"request": {"description": "<p>NEW</p>"}}
def test_update_payload_optional_fields():
both = products.build_update_payload(
description="<p>PC</p>", mobile_description="<p>MO</p>", shop_no=1
)
assert both == {"shop_no": 1, "request": {"description": "<p>PC</p>", "mobile_description": "<p>MO</p>"}}
# 빈 문자열은 "모바일을 비운다"는 뜻이므로 None 과 구분해 전달돼야 한다.
assert products.build_update_payload(description="x", mobile_description="")["request"] == {
"description": "x",
"mobile_description": "",
}
def test_normalize_product_flags():
row = products.normalize_product(_PRODUCT)
assert row == {
"product_no": 131,
"product_code": "P000000B",
"product_name": "빠져락 1개(사은품)",
"display": True,
"selling": False,
}
# 값이 없으면 기본 True(카페24 응답에 필드가 빠진 경우 진열 중으로 본다).
assert products.normalize_product({"product_no": "9"})["display"] is True
class _PagingClient:
"""페이지를 넘겨가며 응답하는 가짜 클라이언트."""
def __init__(self, count: int):
self.count = count
self.calls: list[dict] = []
def get(self, path, *, params=None, json=None, product_no=None):
self.calls.append(dict(params or {}))
offset = int((params or {}).get("offset", 0))
limit = int((params or {}).get("limit", 100))
page = [{"product_no": n} for n in range(offset, min(offset + limit, self.count))]
return {"products": page}
def test_list_all_products_walks_pages():
client = _PagingClient(230)
rows, truncated = products.list_all_products(client)
assert len(rows) == 230 and truncated is False
# 100 + 100 + 30 → 3회 호출로 끝나야 한다.
assert len(client.calls) == 3
assert [c["offset"] for c in client.calls] == [0, 100, 200]
def test_list_all_products_stops_at_cap():
"""상한을 넘으면 잘렸다고 알린다 — 무한 호출로 API 제한에 걸리지 않게."""
client = _PagingClient(10_000)
rows, truncated = products.list_all_products(client, max_items=150)
assert len(rows) == 150 and truncated is True
def test_list_all_products_single_page():
"""현재 쇼핑몰(87개)은 1회 호출로 끝난다."""
client = _PagingClient(87)
rows, truncated = products.list_all_products(client)
assert len(rows) == 87 and truncated is False
assert len(client.calls) == 1
def test_list_products_clamps_paging():
client = _FakeClient({"products": []})
products.list_products(client, limit=999, offset=-5, product_name="")
params = client.calls[0]["params"]
assert params["limit"] == products.PAGE_LIMIT
assert params["offset"] == 0
assert params["product_name"] == ""
# ════════════════════════════════════════════════════════════
# 이미지 URL 한글 파일명 표시 ↔ 저장 (왕복 보존이 핵심)
# ════════════════════════════════════════════════════════════
_REAL_IMG = "%EC%9A%A9%EA%B8%B0%EB%83%84%EC%83%88%EC%B0%A8%EB%8B%A8(%ED%99%A9%ED%86%A0)_12.gif"
_REAL_HTML = f'<img src="/web/product/big/{_REAL_IMG}" alt="용기 냄새차단">'
def test_decode_shows_korean_filename():
decoded = store.decode_html_urls(_REAL_HTML)
assert "용기냄새차단(황토)_12.gif" in decoded
assert "%EC%9A%A9" not in decoded
# 괄호는 원본에서 인코딩돼 있지 않으므로 그대로 남아야 한다.
assert "(황토)" in decoded
def test_url_roundtrip_is_byte_identical():
"""편집하지 않고 적용해도 카페24 저장값이 달라지면 안 된다."""
assert store.encode_html_urls(store.decode_html_urls(_REAL_HTML)) == _REAL_HTML
def test_ascii_escapes_are_not_decoded():
"""%20·%3C 를 풀면 URL·HTML 구조가 깨진다 — 건드리지 않는다."""
html = '<img src="/web/a%20b.png?x=1%3C2">'
assert store.decode_html_urls(html) == html
assert store.encode_html_urls(html) == html
def test_encode_leaves_body_text_alone():
"""본문 한글은 인코딩 대상이 아니다(URL 속성값만 바꾼다)."""
html = '<p>여름 특가 안내</p><a href="/web/여름.html">보기</a>'
encoded = store.encode_html_urls(html)
assert "<p>여름 특가 안내</p>" in encoded
assert 'href="/web/%EC%97%AC%EB%A6%84.html"' in encoded
def test_css_url_is_handled():
html = "<style>.a{background:url(/web/upload/%ED%99%A9%ED%86%A0.png)}</style>"
assert "황토.png" in store.decode_html_urls(html)
assert store.encode_html_urls(store.decode_html_urls(html)) == html
def test_invalid_utf8_sequence_left_alone():
"""EUC-KR 등 UTF-8 이 아닌 이스케이프는 깨뜨리지 않고 그대로 둔다."""
html = '<img src="/web/%C7%CF%B3%AA.gif">'
assert store.decode_html_urls(html) == html
# ════════════════════════════════════════════════════════════
# 소스 정리(포맷) — 렌더링을 바꾸지 않는 것이 최우선
# ════════════════════════════════════════════════════════════
_MESSY = (
'<style>\n\t/* 주석 */\n\t.v{max-width:100%}\n</style>'
'<div class="wrap"><p>안녕<span>하세요</span> 여름 특가</p>'
'<img src="/web/a.gif"><img src="/web/b.gif">'
"<table><tr><td>1</td><td>2</td></tr></table></div>"
)
def test_format_breaks_block_tags():
out = store.format_html(_MESSY)
lines = out.split("\n")
assert '<div class="wrap">' in lines
assert "</div>" in lines
# 블록 안쪽은 들여쓴다.
assert any(line.startswith(" <table>") for line in lines)
assert any(line.startswith(" <td>") for line in lines)
def test_format_keeps_inline_elements_together():
"""이미지 사이에 줄바꿈이 들어가면 화면에 공백이 생긴다 — 붙여둬야 한다."""
out = store.format_html(_MESSY)
assert '<img src="/web/a.gif"><img src="/web/b.gif">' in out
assert "<p>안녕<span>하세요</span> 여름 특가</p>" in out
def test_format_preserves_style_content_verbatim():
out = store.format_html(_MESSY)
assert "\t/* 주석 */" in out
assert "\t.v{max-width:100%}" in out
def test_format_is_idempotent():
"""편집하지 않고 다시 적용해도 저장값이 계속 바뀌면 안 된다."""
once = store.format_html(_MESSY)
assert store.format_html(once) == once
assert store.format_html(store.format_html(once)) == once
_REAL_DETAIL = """<div style="width: 1000px; margin: 0 auto;">
<img src="../img/promo/dadamam_detail1.jpg">
<img src="../img/promo/dadamam_detail2.jpg">
<!-- 대파_타임랩스----------------><img contenteditable="false" src="../img/gif/NEW_1.gif">
<img contenteditable="false" src="../img/2+1/2+1_02.jpg">
</div>"""
def test_format_indents_every_line_of_a_run():
"""원문 줄바꿈을 살리고 **모든 줄**을 들여쓴다.
예전에는 첫 줄만 들여쓰고 나머지가 1열에 붙어 나왔다.
"""
lines = store.format_html(_REAL_DETAIL).split("\n")
img_lines = [line for line in lines if "<img" in line]
assert len(img_lines) == 4, img_lines
assert all(line.startswith(" <") for line in img_lines), img_lines
def test_format_keeps_comment_with_its_element():
"""`<!-- 라벨 --><img>` 는 붙여둔다 — 나누면 라벨과 대상이 떨어진다."""
out = store.format_html(_REAL_DETAIL)
assert "<!-- 대파_타임랩스----------------><img contenteditable=" in out
def test_format_keeps_single_blank_line():
"""구획용 빈 줄은 한 줄까지 유지한다(여러 줄은 하나로)."""
out = store.format_html("<div>\n\n\n<img src=\"a.gif\">\n\n\n<img src=\"b.gif\">\n</div>")
assert "\n\n" in out
assert "\n\n\n" not in out
def test_format_real_detail_is_idempotent():
once = store.format_html(_REAL_DETAIL)
assert store.format_html(once) == once
def test_format_collapses_short_blocks():
assert store.format_html("<td>1</td>") == "<td>1</td>"
# 길면 나눈다.
long_text = "" * 200
assert "\n" in store.format_html("<td>%s</td>" % long_text)
def test_format_survives_broken_html():
"""닫는 태그 누락·꺾쇠 조각이 있어도 예외 없이 뭔가를 돌려준다."""
for bad in ("<div><p>열고 안 닫음", "a < b 그리고 c > d", "<<>>", "<div", ""):
assert isinstance(store.format_html(bad), str)
def test_format_does_not_touch_urls():
"""포맷은 속성값을 건드리지 않는다(인코딩과 서로 간섭하지 않게)."""
html = '<div><img src="/web/%EC%9A%A9%EA%B8%B0(a)_1.gif"></div>'
assert "/web/%EC%9A%A9%EA%B8%B0(a)_1.gif" in store.format_html(html)
def test_format_then_encode_roundtrip():
"""화면 표시(디코딩+정리) → 저장(인코딩+정리) 순서에서 URL 이 원형을 지킨다."""
raw = store.format_html(_REAL_HTML)
shown = store.format_html(store.decode_html_urls(raw))
saved = store.format_html(store.encode_html_urls(shown))
assert saved == raw
# ════════════════════════════════════════════════════════════
# 상단 공통 홍보 숨기기 (스킨 파일을 못 고치므로 상세설명 CSS 로 처리)
# ════════════════════════════════════════════════════════════
def test_promo_hidden_add_and_remove():
body = "<div><img src=\"a.gif\"></div>"
on = store.set_promo_hidden(body, True)
assert store.has_hidden_promo(on) is True
assert ".edb-img-tag-w{display:none !important}" in on
assert body in on # 원래 내용은 그대로 남는다
off = store.set_promo_hidden(on, False)
assert store.has_hidden_promo(off) is False
assert off.strip() == body
def test_promo_hidden_is_idempotent():
body = "<div>x</div>"
once = store.set_promo_hidden(body, True)
assert store.set_promo_hidden(once, True) == once
twice_off = store.set_promo_hidden(store.set_promo_hidden(once, False), False)
assert twice_off.strip() == body
def test_promo_hidden_survives_formatting():
"""정리(포맷)를 거쳐도 숨김 상태가 유지되고 인식된다."""
formatted = store.format_html(store.set_promo_hidden("<div><p>내용</p></div>", True))
assert store.has_hidden_promo(formatted) is True
assert store.has_hidden_promo(store.format_html(store.set_promo_hidden(formatted, False))) is False
def test_promo_hidden_keeps_other_style_blocks():
"""사람이 쓴 <style> 은 건드리지 않는다 — 우리 블록만 id 로 찾는다."""
body = "<style>.v{max-width:100%}</style><div>x</div>"
on = store.set_promo_hidden(body, True)
off = store.set_promo_hidden(on, False)
assert ".v{max-width:100%}" in off
assert off.strip() == body
def test_fingerprint_detects_change():
a = store.fingerprint("<p>A</p>")
assert a == store.fingerprint("<p>A</p>")
assert a != store.fingerprint("<p>B</p>")
assert len(a) == 32
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print("PASS", fn.__name__)
print(f"\n{len(fns)} tests passed.")
if __name__ == "__main__":
_run_all()
+585
View File
@@ -0,0 +1,585 @@
/* 카페24 상품관리 모듈 전용 스타일.
전역(erp.css) 은 건드리지 않는다. 클래스 접두사: cf24-
색상은 erp.css 의 :root 토큰을 재사용한다. */
/* .erp-page 는 flex column + overflow-y:auto 다. 자식의 flex-shrink 기본값이
1 이라 내용이 화면보다 길면 카드가 눌려 잘리고(스크롤도 안 생긴다),
시스템 화면의 [카페24 연결] 버튼처럼 카드 아래쪽이 사라진다.
축소를 막아 넘친 만큼 .erp-page 가 스크롤하게 한다.
이 파일은 카페24 템플릿에서만 로드되므로 다른 모듈에는 영향이 없다. */
.erp-page > * {
flex-shrink: 0;
}
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
.erp-page {
max-width: none;
box-sizing: border-box;
}
.cf24-card {
margin-bottom: var(--sp-16, 16px);
}
/* ════════════════════════════════════════════════════════════
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
════════════════════════════════════════════════════════════ */
.cf24-split {
display: grid;
grid-template-columns: 550px minmax(0, 1fr);
gap: var(--sp-12, 12px);
align-items: stretch;
}
/* .erp-page 는 flex column 이다. 위 flex-shrink:0 규칙의 예외로, 분할 영역만은
남은 높이를 모두 차지하고 안에서 스크롤되게 한다(휴가/쿠팡 모듈과 같은 방식). */
.erp-page > .cf24-split {
flex: 1 1 auto;
min-height: 0;
}
.cf24-pane {
min-height: 0;
overflow: auto;
margin-bottom: 0;
padding: var(--sp-12, 12px);
}
.cf24-pane-list {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
overflow: hidden; /* 표만 스크롤 — 검색/필터는 고정 */
}
.cf24-pane-editor {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
padding: var(--sp-16, 16px);
}
@media (max-width: 1100px) {
.cf24-split {
grid-template-columns: minmax(0, 1fr);
}
.cf24-pane {
overflow: visible;
}
.cf24-pane-list {
max-height: 45vh;
}
}
/* ── 왼쪽: 검색·필터 ── */
.cf24-filters {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
flex: 0 0 auto;
}
.cf24-checks {
display: flex;
gap: var(--sp-12, 12px);
font-size: var(--text-caption, 12px);
}
.cf24-checks label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.cf24-list-count {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
/* ── 왼쪽: 목록 표 (좁게) ── */
.cf24-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.cf24-list-table {
font-size: 12px;
table-layout: fixed;
width: 100%;
}
.cf24-list-table thead th {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 4px;
white-space: nowrap;
}
.cf24-list-table tbody td {
padding: 6px 4px;
vertical-align: top;
}
/* 목록 폭 550px 기준. 고정폭을 뺀 나머지(약 290px)가 상품명 몫이다.
진열/판매 칸은 제목(2글자) + 정렬 화살표가 들어갈 만큼만. */
.cf24-col-no { width: 40px; text-align: right; color: var(--color-midtone-gray, #737373); }
.cf24-col-flag { width: 40px; text-align: center; }
.cf24-col-date { width: 78px; white-space: nowrap; color: var(--color-midtone-gray, #737373); }
.cf24-col-name { word-break: break-word; }
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
.cf24-col-name a {
color: inherit;
text-decoration: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.cf24-row {
cursor: pointer;
}
.cf24-row:hover {
background: var(--color-ghost-gray, #f2f2f2);
}
.cf24-row.is-active {
background: var(--color-rich-black, #0a0a0a);
}
.cf24-row.is-active td,
.cf24-row.is-active .cf24-col-name a {
color: #fff;
}
/* 진열/판매 표시는 좁은 칸이라 배지 대신 점으로 */
.cf24-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-subtle-ash, #e5e5e5);
}
.cf24-dot-on {
background: var(--color-success-green, #10c22b);
}
/* 제목행 클릭 정렬 */
.cf24-sortable {
cursor: pointer;
user-select: none;
}
.cf24-sortable::after {
content: "↕";
opacity: 0.35;
margin-left: 2px;
}
.cf24-sortable.is-asc::after { content: "▲"; opacity: 1; }
.cf24-sortable.is-desc::after { content: "▼"; opacity: 1; }
/* ── 오른쪽: 편집기 ── */
.cf24-editor-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-12, 12px);
flex: 0 0 auto;
}
.cf24-editor-title {
margin: 0;
font-size: var(--text-heading, 18px);
letter-spacing: -0.45px;
}
.cf24-editor-sub {
margin: 4px 0 0;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-editor-badges {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
.cf24-editor-form {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: var(--sp-8, 8px);
}
.cf24-editor-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-8, 8px);
flex-wrap: wrap;
flex: 0 0 auto;
}
.cf24-editor-bar-right {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
}
.cf24-check-inline {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: var(--sp-12, 12px);
cursor: pointer;
color: var(--color-rich-black, #0a0a0a);
}
.cf24-memo {
width: 260px;
padding: 6px var(--sp-10, 10px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
font-size: 12px;
}
.cf24-html-main {
flex: 1 1 auto;
min-height: 340px;
}
/* ════════════════════════════════════════════════════════════
문법 강조 편집기
투명한 <textarea> 를 색칠된 <pre> 위에 정확히 겹쳐 놓는 방식이다.
두 요소의 폰트·줄높이·여백·줄바꿈 규칙이 **완전히 같아야** 글자가 어긋나지
않는다. 아래 두 선택자에 붙은 속성을 바꿀 때는 반드시 함께 바꿀 것.
외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙 + CDN 의존 제거).
════════════════════════════════════════════════════════════ */
/* 스크롤은 **textarea 가** 담당하고, 색칠 층과 줄 번호가 transform 으로 따라간다.
폭·높이를 계산해 맞추는 방식은 flex 안에서 `width: max-content` 가 기대대로
동작하지 않아 실패했다 — 긴 줄이 있으면 textarea 만 내부 스크롤되고 색칠 층은
제자리에 남아, 커서 위치와 보이는 글자가 어긋났다(실측 706 vs 686).
스크롤 동기화 방식은 크기 계산이 아예 필요 없어 어긋날 여지가 없다. */
.cf24-code {
position: relative;
flex: 1 1 auto;
min-height: 340px;
overflow: hidden;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
background: var(--color-canvas-white, #fff);
}
/* 줄 번호 — 코드 영역 왼쪽에 고정된 별도 칸(가로 스크롤과 무관) */
.cf24-gutter {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 42px;
overflow: hidden;
padding: var(--sp-12, 12px) var(--sp-8, 8px) 0 0;
text-align: right;
background: var(--color-ghost-gray, #f6f8fa);
border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
color: #8c959f;
user-select: none;
white-space: pre;
}
.cf24-gutter-inner {
will-change: transform; /* 세로 스크롤을 따라 움직인다 */
}
.cf24-code-body {
position: absolute;
left: 43px; /* 줄 번호 칸 + 경계선 */
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
}
/* 두 층의 글자가 정확히 겹치려면 아래 속성이 전부 같아야 한다.
줄 번호(.cf24-gutter)도 같은 글꼴 지표를 써야 줄이 맞는다. */
.cf24-gutter,
.cf24-code-hl,
.cf24-code-input {
margin: 0;
border: 0;
box-sizing: border-box;
font-family: var(--font-geist-mono, ui-monospace, "Consolas", monospace);
font-size: 12px;
line-height: 1.6;
tab-size: 2;
}
.cf24-code-hl,
.cf24-code-input {
padding: var(--sp-12, 12px);
white-space: pre; /* 줄바꿈하지 않는다 — 줄 번호가 어긋나지 않게 */
overflow-wrap: normal;
}
/* 색칠 층 — textarea 의 스크롤량만큼 transform 으로 이동한다. */
.cf24-code-hl {
position: absolute;
top: 0;
left: 0;
width: max-content;
min-width: 100%;
pointer-events: none;
color: var(--color-rich-black, #0a0a0a);
will-change: transform;
}
/* textarea 가 실제 스크롤 주체다. */
.cf24-code-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
background: transparent;
color: transparent;
caret-color: var(--color-rich-black, #0a0a0a);
resize: none;
overflow: auto;
}
.cf24-code-input:focus {
outline: none;
}
.cf24-code:focus-within {
border-color: var(--color-rich-black, #0a0a0a);
}
/* 선택 영역이 보이게 (글자는 투명이므로 배경만 남는다) */
.cf24-code-input::selection {
background: #b3d4fc;
}
/* 토큰 색 — GitHub 라이트 계열 */
.cf24-t-tag { color: #116329; } /* 태그 이름 */
.cf24-t-attr { color: #953800; } /* 속성 이름 */
.cf24-t-val { color: #0a3069; } /* 속성 값 */
.cf24-t-pun { color: #57606a; } /* < > / = */
.cf24-t-com { color: #6e7781; font-style: italic; } /* 주석 */
.cf24-t-doc { color: #6639ba; } /* DOCTYPE 등 선언 */
.cf24-code-hint {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-details {
flex: 0 0 auto;
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
padding-top: var(--sp-8, 8px);
}
.cf24-details > summary {
cursor: pointer;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-compact {
font-size: 12px;
}
.cf24-compact tbody td,
.cf24-compact thead th {
padding: 6px 8px;
}
.cf24-empty-pane {
margin: auto;
text-align: center;
padding: var(--sp-24, 24px);
}
.cf24-empty-pane h3 {
margin: 0 0 var(--sp-8, 8px);
}
.cf24-card-head {
display: flex;
align-items: center;
gap: var(--sp-8, 8px);
margin-bottom: var(--sp-12, 12px);
}
.cf24-card-head h3 {
margin: 0;
font-size: var(--text-heading, 18px);
letter-spacing: -0.45px;
}
.cf24-muted {
color: var(--color-midtone-gray, #737373);
font-size: var(--text-caption, 12px);
}
.cf24-err {
color: var(--color-callout-red, #c22b10);
}
.cf24-nowrap {
white-space: nowrap;
}
/* 상태 배지 */
.cf24-badge-ok {
background: var(--color-success-green, #10c22b);
color: #fff;
}
.cf24-badge-off {
background: var(--color-ghost-gray, #f2f2f2);
color: var(--color-rich-black, #0a0a0a);
}
/* 안내/오류 배너 */
.cf24-flash {
padding: var(--sp-10, 10px) var(--sp-12, 12px);
border-radius: var(--r-lg, 10px);
margin-bottom: var(--sp-12, 12px);
font-size: var(--text-body, 14px);
}
.cf24-flash-ok {
background: #eefaf0;
border: 1px solid var(--color-success-green, #10c22b);
}
.cf24-flash-err {
background: #fdefec;
border: 1px solid var(--color-callout-red, #c22b10);
}
/* 키-값 표 */
.cf24-kv th {
width: 180px;
text-align: left;
color: var(--color-midtone-gray, #737373);
font-weight: 500;
white-space: nowrap;
}
.cf24-actions {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
margin-top: var(--sp-12, 12px);
}
.cf24-warn {
color: var(--color-callout-red, #c22b10);
font-size: var(--text-caption, 12px);
font-weight: 500;
}
/* 검색 도구모음 / 페이지 이동 */
.cf24-toolbar {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
flex-wrap: wrap;
margin-bottom: var(--sp-12, 12px);
}
/* 한 줄 높이로 고정한다. 세로 flex 안에서 flex-basis 를 주면 그 값이 '높이'로
적용돼 입력란이 거대해진다(실제로 그랬다 — flex 방향을 항상 확인할 것). */
.cf24-search {
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
height: 32px;
padding: 0 var(--sp-10, 10px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
font-size: var(--text-body, 14px);
}
.cf24-pager {
display: flex;
gap: var(--sp-12, 12px);
align-items: center;
justify-content: center;
margin-top: var(--sp-16, 16px);
}
/* 상세설명 HTML 원문 */
.cf24-label {
display: block;
margin: var(--sp-16, 16px) 0 var(--sp-8, 8px);
font-size: var(--text-caption, 12px);
font-weight: 500;
color: var(--color-midtone-gray, #737373);
}
.cf24-html {
width: 100%;
box-sizing: border-box;
padding: var(--sp-12, 12px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
background: var(--color-canvas-white, #fff);
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.6;
white-space: pre;
overflow: auto;
resize: vertical;
}
/* 읽기 전용은 배경으로 구분 — 편집 가능한 칸과 헷갈리지 않게 */
.cf24-html[readonly] {
background: var(--color-ghost-gray, #f2f2f2);
}
.cf24-note {
margin: var(--sp-12, 12px) 0 0;
padding: var(--sp-10, 10px) var(--sp-12, 12px);
border-left: 3px solid var(--color-subtle-ash, #e5e5e5);
background: var(--color-ghost-gray, #f2f2f2);
border-radius: var(--r-sm, 6px);
color: var(--color-midtone-gray, #737373);
font-size: var(--text-caption, 12px);
line-height: 1.7;
}
/* 넓은 로그 표는 카드 안에서만 가로 스크롤 */
.cf24-scroll {
overflow-x: auto;
}
.cf24-scroll code {
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
}
.cf24-empty {
padding: var(--sp-24, 24px);
}
.cf24-empty h3 {
margin: 0 0 var(--sp-8, 8px);
}
+1
View File
@@ -31,6 +31,7 @@ MODULE_KEYS: tuple[str, ...] = (
"malaysia", "malaysia",
"dispatch", "dispatch",
"project", "project",
"cafe24",
"expense_approver", "expense_approver",
"vacation_approver", "vacation_approver",
) )
+339
View File
@@ -0,0 +1,339 @@
# 카페24 상품 상세페이지 관리 모듈 (cafe24)
> 카페24 관리자 페이지에 직접 들어가지 않고 상품 상세페이지를 조회·편집·예약
> 적용하고, 언제든 이전 상태로 되돌리기 위한 운영용 모듈.
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
> OAuth + 권한키 `cafe24` 를 재사용한다.
---
## 1. 구조 — 왜 두 곳으로 나눴나
향후 **카페24 주문관리**(주문 조회·송장 일괄등록·취소/반품/교환)를 같은
프로젝트에 추가할 예정이다. 그래서 카페24 인증/전송은 상품관리에 종속시키지
않고 공통 계층으로 분리했다.
```
app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유)
├─ config.py 환경변수 → Cafe24Config (하드코딩 금지)
├─ crypto.py 토큰 Fernet 암복호화
├─ oauth.py 인증 URL / code→token / refresh
├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그
├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가)
└─ errors.py 공통 예외
app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
_editor.html(오른쪽 조각) · schedules.html · system.html
```
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
`app.integrations.cafe24``Cafe24Client` 를 통한다(재시도·로그·토큰 갱신이
한 곳에 모여 있어야 하기 때문).
핸들러는 `async def` 가 아니라 **`def`(동기)** 로 선언한다. 카페24 API·DB 호출이
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
---
## 2. 화면 / 경로
| 경로 | 화면 | 권한 |
| --- | --- | --- |
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
| `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** |
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
---
## 3. OAuth 흐름
```
관리자 [카페24 연결]
↓ state 생성 → 세션 저장
GET /cafe24/system/oauth/start → 302 카페24 인증 페이지
↓ 사용자 승인
GET /cafe24/oauth/callback?code=&state=
↓ 세션 state 와 대조 (불일치 시 토큰 교환 거부 — CSRF 방어)
code → access/refresh token 교환
↓ Fernet 암호화
cafe24_oauth_tokens 저장
```
- scope 는 `app/integrations/cafe24/config.py``PRODUCT_SCOPES` =
`mall.read_product`, `mall.write_product`.
주문관리 추가 시 `ORDER_SCOPES` 를 합쳐 넘기고, 카페24 개발자센터 앱에서도
권한을 추가한 뒤 **재인증**하면 된다.
- access token 은 만료 2분 전부터 자동 갱신된다. 갱신은 토큰 행을
`SELECT ... FOR UPDATE` 로 잠근 채 수행 — web 컨테이너와 worker 컨테이너가
동시에 refresh 해서 한쪽 토큰이 무효화되는 것을 막는다(카페24는 refresh
token 을 회전시킨다).
- refresh token 이 만료되면 자동 복구가 불가능하므로 화면에 "재연결 필요"를
표시한다.
---
### 2-1. 상품관리 화면 구성 (2분할)
```
┌─ 550px ───────────────────┬───────── 남은 폭 전부 ─────────┐
│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매 │
│ ☐진열중 ☐판매중 │ PC 상세설명 HTML 편집기 │
│ ── 목록(전체, 스크롤) ── │ (문법 강조 · 남은 높이 전부) │
│ 번호 상품명 진열 판매 수정 │ [메모] [복사] [카페24에 적용] │
│ (제목행 클릭 = 정렬) │ ▸ 모바일 HTML(분리 상품만) │
│ │ ▸ 버전 이력 │
└───────────────────────────┴─────────────────────────────────┘
```
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
- **왼쪽은 전체 목록**(페이지 없음). `list_all_products` 로 페이지를 넘겨가며 전부
받는다(1회 100개, 상한 1000개). 필터를 한 페이지에만 적용하면 다음 페이지의
해당 상품이 빠지기 때문이다.
- **필터**는 `진열중`/`판매중` 체크박스이며 **중복 선택 시 AND** 다. 문서에 없는 API
파라미터에 기대지 않고 받아온 뒤 파이썬에서 걸러낸다.
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
덕분에 목록 전체가 대상이 된다.
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
- 편집 중 다른 상품을 클릭하거나 페이지를 벗어나면 **저장 안 됨 경고**가 뜬다.
- `.erp-page``max-width` 를 이 화면에서만 풀어 편집 영역을 넓게 쓴다. 이때
`box-sizing: border-box` 를 함께 줘야 한다(안 주면 padding 이 폭에 더해져 문서에
가로 스크롤이 생긴다).
---
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를
추측해서 쓰지 말 것.
- **`/admin/products/{no}/description` 서브리소스는 존재하지 않는다.**
호출하면 `No API found.` 가 온다. 상세설명은 **상품 리소스의 필드**다.
```
GET /admin/products/{no} → description · mobile_description ·
separated_mobile_description
PUT /admin/products/{no} → {"request": {"description": "..."}}
```
- **목록 API(`GET /admin/products`) 응답에는 `description` 이 없다.**
그래서 상세설명은 상품 1건씩 조회해야 하고, 목록 화면에 미리보기를 뿌리지
않는다(상품 87개 × 1호출 = 호출 제한 위험).
- **PC/모바일 상세설명이 분리되어 있다.** `separated_mobile_description`
(`'T'`/`'F'`) 이 분리 사용 여부다. `'F'`(미분리) 상품을 수정할 때는
`description` 과 `mobile_description` 을 같은 HTML 로 함께 맞춘다.
`'T'` 면 두 값을 따로 관리해야 한다.
- 그 밖에 상세 응답에만 있는 참고 필드: `translated_description`(다국어),
`summary_description`(요약설명), `simple_description`, `shop_no`(멀티쇼핑몰).
- **이미지 경로의 한글은 퍼센트 인코딩되어 저장된다.**
```
src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
```
사람이 읽을 수 없으므로 화면에서는 `store.decode_html_urls` 로 풀어서 보여주고,
저장할 때 `store.encode_html_urls` 로 되돌린다. 두 함수는 서로의 역이며
**왕복이 보존된다**(편집하지 않고 적용해도 저장값이 바뀌지 않는다 — 테스트로 고정).
안전 규칙: 디코딩은 non-ASCII(`%80`~`%FF`)만, 인코딩은 URL 속성값 안만.
`%20`·`%3C` 를 풀거나 본문 한글을 인코딩하면 페이지가 깨진다.
---
### 2-2. 편집기 (문법 강조 · 소스 정리)
**문법 강조** — 색칠된 `<pre>` 위에 **투명한 `<textarea>`** 를 정확히 겹쳐 놓는
방식이다. 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
- 두 층의 **폰트·글자크기·줄높이·padding·`white-space`·`tab-size` 가 완전히 같아야**
글자가 어긋나지 않는다. 줄 번호 칸(`.cf24-gutter`)도 같은 글꼴 지표를 써야 줄이
맞는다. `cafe24.css` 의 세 선택자를 항상 함께 수정할 것.
- **크기는 `<pre>` 가 정하고 textarea 가 `inset:0` 으로 그 위를 덮는다.** JS 로 높이를
계산하지 않는다(textarea 의 `scrollHeight` 는 브라우저가 한두 줄 더 잡아 어긋난다 —
실측 830 vs 792). 스크롤은 바깥 `.cf24-code` 가 담당한다.
- **줄바꿈하지 않고 가로로 스크롤한다**(`white-space: pre` + `wrap="off"`).
줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어 줄 번호가 맞지 않는다.
- 줄 번호 칸은 `position: sticky; left: 0` 이라 가로로 스크롤해도 왼쪽에 남는다.
- 20만 자를 넘으면 강조를 끄고 평문으로 보여준다(타이핑마다 재색칠하면 느려짐).
- 색: 태그 초록 / 속성이름 갈색 / 속성값 남색 / 주석 회색 기울임 / 기호 회색.
**소스 정리** — `store.format_html()`. 화면에 보여줄 때와 저장할 때 **같은 함수**를
쓰므로, 화면에서 본 소스가 그대로 카페24에 저장된다.
- 줄을 나누는 것은 **블록 요소 경계에서만** 한다. HTML 에서 공백은 의미가 있어서
인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 벌어지는
고전적인 사고). `img`·`br`·`span`·`a` 는 블록 목록에서 **의도적으로 제외**했다.
- **원문에 이미 있던 줄바꿈은 살리고, 각 줄을 현재 깊이로 들여쓴다.** 상세페이지는
`<img>` 를 한 줄에 하나씩 적어두는 경우가 많고 그 모양이 저자의 의도다. 줄 앞
공백은 렌더링에 영향이 없으므로 들여쓰기는 안전하다.
- 주석은 줄을 강제로 나누지 않는다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤
요소를 설명하는 주석이 많아, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.
- 구획용 **빈 줄은 한 줄까지 유지**한다(여러 줄은 하나로 줄인다).
- `<style>`·`<script>`·`<pre>`·`<textarea>` 안쪽은 한 글자도 건드리지 않는다.
- 내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다(`<td>1</td>`).
- **멱등**이다 — 편집하지 않고 다시 적용해도 저장값이 계속 바뀌지 않는다(테스트로 고정).
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
### 2-3. 상단 공통 홍보 숨기기
스킨(`detail.html`)에는 공통 홍보를 지울 상품번호가 박혀 있다.
```js
const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
```
**카페24 Admin API 로는 이 파일을 고칠 수 없다.** 확인 결과 스킨/테마는 조회만
가능하고(`GET /admin/themes`), 스킨 HTML 파일을 읽거나 쓰는 엔드포인트가 없다.
쓸 수 있는 것은 테마 페이지와 스크립트 태그(`/admin/scripttags`)뿐이다.
그래서 같은 결과를 **상품 상세설명 안의 CSS** 로 낸다. 상세설명은 우리가 쓸 수 있고,
상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
```html
<style id="cf24-hide-common-promo">/* DBX ERP: 상단 공통 홍보 숨김 */
.edb-img-tag-w{display:none !important}
</style>
```
- 편집기 상단 **「상단 공통 홍보 숨기기」** 체크박스로 켜고 끈다. 적용할 때 반영된다.
- `id` 로 우리 블록만 찾는다 — 사람이 쓴 `<style>` 은 건드리지 않는다.
- **PC·모바일 모두 적용된다.** 미분리 상품은 같은 HTML 이 양쪽에 들어가고,
분리 상품은 모바일 본문은 그대로 두고 이 블록만 모바일에도 맞춘다(양쪽에 걸어야
효과가 있다).
- 넣기/빼기가 멱등이며 포맷을 거쳐도 상태가 유지된다(테스트로 고정).
- 스킨의 `numbers` 목록과는 독립이다. 이미 목록에 있는 상품은 그대로 두면 된다
(스킨은 요소를 제거하고, 이쪽은 CSS 로 숨긴다 — 결과는 같고 충돌하지 않는다).
> 스킨의 `numbers` 방식을 그대로 자동화하려면 `/admin/scripttags` 로 전역 스크립트를
> 주입하는 방법이 있다. 다만 **디자인 쓰기 권한 추가 + 재인증**이 필요하고, 상품별
> 상태가 한 곳에 몰려 관리가 어려워진다. 지금은 채택하지 않았다.
---
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
이 순서를 절대 바꾸지 않는다.
0. 제출된 HTML 을 `encode_html_urls` → `format_html` 순으로 다듬는다(화면에서 본
정리된 소스가 그대로 저장된다).
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
덮어쓰는 것을 막는 낙관적 잠금이다.
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`).
추가 규칙:
- 빈 내용은 거부한다(상세페이지 전체를 날리는 실수 방지).
- **PC/모바일 미분리(`separated_mobile_description='F'`) 상품은 모바일 필드도
같은 HTML 로 함께 쓴다.** PC 만 바꾸면 모바일이 어긋난다.
분리(`'T'`) 상품은 모바일을 건드리지 않고, 화면에 "모바일은 따로 반영" 을 알린다.
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
---
## 4. 보안 규칙 (반드시 지킬 것)
- `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`.
- **로그·예외 메시지·템플릿에 토큰/시크릿을 절대 출력하지 않는다.**
`cafe24_api_logs` 에도 Authorization 헤더를 기록하지 않는다.
- 토큰은 DB 에 Fernet 암호문으로만 저장한다(`CAFE24_TOKEN_SECRET`).
- 카페24 연결/해제는 `is_admin` 전용.
- OAuth 콜백은 세션 `state` 대조 후에만 code 를 교환한다.
- SQL 은 `%s` 플레이스홀더만 사용한다(문자열 조립 금지).
---
## 5. 설치 / 실행
### 5-1. 카페24 개발자센터 앱 등록 (사람이 해야 하는 일)
1. <https://developers.cafe24.com> 로그인 → 앱 생성
2. Redirect URI 를 `.env` 의 `CAFE24_REDIRECT_URI` 와 **정확히 동일하게** 등록
(운영: `https://dbx.no1king.freeddns.org/cafe24/oauth/callback`)
3. 권한(Scope)에 `mall.read_product`, `mall.write_product` 체크
4. 발급된 Client ID / Client Secret 을 `.env` 에 기입
### 5-2. DB 초기화 (superuser 로 1회)
```bash
read -s -p "cafe24_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cafe24_db_init.sql
```
### 5-3. .env
```
CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
CAFE24_MALL_ID=miraskitchen
CAFE24_CLIENT_ID=...
CAFE24_CLIENT_SECRET=...
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
CAFE24_API_VERSION=2026-03-01
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
```
### 5-4. 재기동 + 권한 부여
```bash
cd /opt/www/main && docker compose up -d --build web
```
관리자 페이지(`/admin`)에서 직원에게 **카페24 상품관리** 권한을 부여한다.
---
## 6. 테스트
```bash
python -m app.modules.cafe24.tests.test_cafe24
```
DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노출(토큰 미유출),
재시도 예산, 예약 상태 전이를 검증한다.
---
## 7. 진행 상태
| Phase | 내용 | 상태 |
| --- | --- | --- |
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 문법 강조 편집기 + 소스 정리 완료. 미리보기·Diff·초안 예정 |
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
| 6 | 자동 종료/복원 · 롤백 | 예정 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | 예정 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
`docker-compose.yml` 에 같은 이미지로 `dbx-cafe24-worker` 서비스를 추가해
`python -m app.modules.cafe24.worker --loop 60` 으로 돌린다.
+41
View File
@@ -10,6 +10,7 @@
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 | | `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 | | `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 | | `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 |
| `cafe24_db` | 카페24 연동 — OAuth 토큰/상품 캐시/상세페이지 버전/예약/감사·API 로그 |
--- ---
@@ -290,6 +291,46 @@ cd /opt/www/main && docker compose up -d --build
--- ---
## cafe24_db 스키마 / 초기화
DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이블·인덱스·트리거를 한 번에 생성. **JSON 폴백 없음**`CAFE24_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 |
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
| `cafe24_api_logs` | 카페24 API 호출 기록. **토큰/Authorization/client_secret 미기록** |
핵심 규칙:
- 카페24에 쓰기 직전 **반드시 현재 HTML 을 다시 조회해 `BACKUP` revision 으로 저장**한다. 로컬 DB 의 마지막 값을 현재값으로 가정하지 않는다.
- 예약의 `restore_revision_id`**예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
- 토큰 암호화 키는 `.env``CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
### 운영 서버 초기화 (1회, 사용자 승인 후)
```bash
read -s -p "cafe24_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cafe24_db_init.sql
# main-app .env 에 추가:
# CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
# CAFE24_MALL_ID / CAFE24_CLIENT_ID / CAFE24_CLIENT_SECRET / CAFE24_REDIRECT_URI
# CAFE24_TOKEN_SECRET=$(openssl rand -hex 32)
cd /opt/www/main && docker compose up -d --build web
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 상세는 `docs/CAFE24_MODULE.md`.
---
## 백업 / 복구 (안전 절차) ## 백업 / 복구 (안전 절차)
### 백업 ### 백업
+2
View File
@@ -10,3 +10,5 @@ python-multipart>=0.0.20
openpyxl>=3.1 openpyxl>=3.1
pdfplumber>=0.11 pdfplumber>=0.11
pillow>=10.0 pillow>=10.0
# 카페24 OAuth 토큰 암호화 저장(Fernet) — app/integrations/cafe24/crypto.py
cryptography>=42.0
+235
View File
@@ -0,0 +1,235 @@
-- =====================================================================
-- cafe24_db 초기화 스크립트 (PostgreSQL) — 카페24 상품 상세페이지 관리
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "cafe24_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/cafe24_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build web
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - OAuth 토큰(access/refresh)은 애플리케이션에서 Fernet 으로 암호화한 뒤
-- 저장한다. 키는 .env 의 CAFE24_TOKEN_SECRET. 이 DB 에 평문 토큰은 없다.
-- - cafe24_api_logs 에는 Authorization 헤더/토큰/client_secret 을 절대
-- 기록하지 않는다(엔드포인트·상태코드·소요시간·오류메시지만).
-- - 향후 카페24 주문관리 모듈도 이 DB(특히 cafe24_oauth_tokens)를 재사용한다.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE cafe24_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cafe24_db')
\gexec
-- 앱 전용 로그인 역할
SELECT 'CREATE ROLE cafe24_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cafe24_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE cafe24_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE cafe24_db TO cafe24_app;
-- cafe24_db 컨텍스트로 전환
\connect cafe24_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION cafe24_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) OAuth 토큰 (쇼핑몰 1개당 1행)
-- access_token / refresh_token 은 Fernet 암호문(TEXT)으로 저장한다.
-- 상품관리 + 향후 주문관리가 공유한다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_oauth_tokens (
id BIGSERIAL PRIMARY KEY,
mall_id TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scopes TEXT NOT NULL DEFAULT '',
last_refreshed_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
connected_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_cafe24_oauth_tokens_updated ON cafe24_oauth_tokens;
CREATE TRIGGER trg_cafe24_oauth_tokens_updated
BEFORE UPDATE ON cafe24_oauth_tokens
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 상품 캐시 (source of truth 는 언제나 Cafe24. 목록/검색 속도용)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_products (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL UNIQUE,
product_code TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
display BOOLEAN NOT NULL DEFAULT TRUE,
selling BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);
DROP TRIGGER IF EXISTS trg_cafe24_products_updated ON cafe24_products;
CREATE TRIGGER trg_cafe24_products_updated
BEFORE UPDATE ON cafe24_products
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
-- revision_type:
-- SYNC Cafe24 에서 읽어온 현재값 스냅샷
-- DRAFT 저장만 한 초안(미적용)
-- BACKUP Cafe24 에 쓰기 직전 자동 백업 ← 복원 기준
-- MANUAL 즉시 적용한 내용
-- SCHEDULED 예약으로 적용한 내용
-- ROLLBACK 과거 버전을 되돌린 내용
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_revisions (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
html_content TEXT NOT NULL DEFAULT '',
revision_type TEXT NOT NULL DEFAULT 'DRAFT',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_cafe24_revision_type CHECK (
revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK')
)
);
CREATE INDEX IF NOT EXISTS idx_cafe24_revisions_product
ON cafe24_product_revisions (product_no, created_at DESC, id DESC);
-- ════════════════════════════════════════════════════════════
-- 4) 예약 작업
-- 한 상품 = 한 행. 일괄 예약은 parent_job_id 로 묶되 행은 개별이므로
-- 한 상품 실패가 나머지를 막지 않는다.
-- end_at/end_action: 프로모션 종료 후 자동 복원용.
-- restore = 적용 직전 BACKUP(restore_revision_id)으로 되돌림
-- revision = end_revision_id 를 적용
-- restore_revision_id 는 예약 "실행 순간" Cafe24 에서 다시 읽어 만든
-- BACKUP revision 을 가리킨다(예약 생성 시점 값이 아님).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_schedules (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE RESTRICT,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
restore_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
end_at TIMESTAMPTZ,
end_action TEXT NOT NULL DEFAULT '',
end_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
parent_job_id TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CONSTRAINT chk_cafe24_schedule_status CHECK (
status IN ('PENDING','PROCESSING','SUCCESS','FAILED','CANCELLED')
),
CONSTRAINT chk_cafe24_schedule_end_action CHECK (
end_action IN ('','restore','revision')
)
);
-- worker 의 due 조회 인덱스 (PENDING + 시간순)
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_due
ON cafe24_product_schedules (scheduled_at)
WHERE status = 'PENDING';
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_product
ON cafe24_product_schedules (product_no, scheduled_at DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_parent
ON cafe24_product_schedules (parent_job_id);
DROP TRIGGER IF EXISTS trg_cafe24_schedules_updated ON cafe24_product_schedules;
CREATE TRIGGER trg_cafe24_schedules_updated
BEFORE UPDATE ON cafe24_product_schedules
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 작업 감사 로그 (누가 무엇을 바꿨나)
-- worker 가 수행한 작업은 actor='SCHEDULER'.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_audit_logs (
id BIGSERIAL PRIMARY KEY,
actor TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT '',
product_no BIGINT,
revision_id BIGINT,
schedule_id BIGINT,
result TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_created ON cafe24_audit_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_product ON cafe24_audit_logs (product_no, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 6) Cafe24 API 호출 로그 (실패 분석용 최소 정보)
-- ⚠️ Authorization 헤더 / access_token / refresh_token / client_secret 은
-- 절대 저장하지 않는다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_api_logs (
id BIGSERIAL PRIMARY KEY,
endpoint TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
product_no BIGINT,
http_status INTEGER,
result TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_created ON cafe24_api_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_status ON cafe24_api_logs (http_status);
-- ════════════════════════════════════════════════════════════
-- 7) 권한 (cafe24_app: CRUD only, DDL 없음)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cafe24_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
cafe24_oauth_tokens, cafe24_products, cafe24_product_revisions,
cafe24_product_schedules, cafe24_audit_logs, cafe24_api_logs
TO cafe24_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO cafe24_app;
SELECT 'cafe24_db ready' AS status;