Files
dbx-main/app/modules/cafe24/routes_system.py
T
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

163 lines
5.6 KiB
Python

"""카페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)