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,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)
|
||||
Reference in New Issue
Block a user