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