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>
This commit is contained in:
@@ -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,17 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
<div class="erp-card cf24-empty">
|
||||
<h3>상품 목록은 Phase 2 에서 열립니다.</h3>
|
||||
<p>
|
||||
먼저 <a href="/cafe24/system">시스템 → 카페24 연결</a>에서 인증을 완료하세요.
|
||||
연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user