Files
dbx-main/app/store.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

257 lines
9.4 KiB
Python

"""JSON 기반 사용자/권한 저장소.
- app/data/users.json 에 사용자 레코드 저장
- 동시성: 파일락 대신 process-내 threading.Lock + 원자적 쓰기(temp → rename)
- 운영시 컨테이너 한 대 가정. 다중 인스턴스 필요해지면 DB 로 교체.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import Any, Iterable
from .timezone import now_kst_iso
# 슈퍼 관리자 — 강등/삭제 불가
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
# 시스템에서 지원하는 권한 키.
# - 접근권한: 모듈 페이지 진입 허용
# - 승인자: 결재 워크플로에서 승인/반려 가능
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
MODULE_KEYS: tuple[str, ...] = (
"corm",
"order",
"expense",
"vacation",
"cupang",
"malaysia",
"dispatch",
"project",
"cafe24",
"expense_approver",
"vacation_approver",
)
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
def _now_iso() -> str:
return now_kst_iso()
class UserStore:
def __init__(self, path: Path):
self._path = path
self._lock = threading.Lock()
self._path.parent.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._write_atomic({"users": {}})
def _read(self) -> dict[str, Any]:
try:
with self._path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
data = {"users": {}}
if "users" not in data or not isinstance(data["users"], dict):
data["users"] = {}
return data
def _write_atomic(self, data: dict[str, Any]) -> None:
fd, tmp = tempfile.mkstemp(
prefix=".users.", suffix=".json.tmp", dir=str(self._path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self._path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
@staticmethod
def _default_modules(is_admin: bool) -> dict[str, bool]:
return {key: is_admin for key in MODULE_KEYS}
@staticmethod
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
# 신규(modules 미지정) 사용자는 역할 기본값(admin=전체ON / user=전체OFF)으로 시작.
# 기존 값이 있으면 그대로 존중한다 — admin 이라도 슈퍼관리자가 개별 모듈을
# 끌 수 있다(예전엔 admin 을 무조건 전체 ON 으로 덮어썼다).
base = UserStore._default_modules(is_admin)
if isinstance(modules, dict):
for key in MODULE_KEYS:
if key in modules:
base[key] = bool(modules[key])
return base
def get(self, email: str) -> dict[str, Any] | None:
email = email.lower().strip()
with self._lock:
data = self._read()
user = data["users"].get(email)
if user is None:
return None
return self._enrich(email, user)
def list_all(self) -> list[dict[str, Any]]:
with self._lock:
data = self._read()
return [self._enrich(email, rec) for email, rec in data["users"].items()]
def create_user(
self,
*,
email: str,
name: str = "",
role: str = "user",
modules: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
email = email.lower().strip()
if not email or "@" not in email:
raise ValueError("올바른 이메일 형식이 아닙니다.")
if role not in ("admin", "user"):
raise ValueError(f"role 값이 잘못되었습니다: {role}")
is_super = email == SUPER_ADMIN_EMAIL
with self._lock:
data = self._read()
if email in data["users"]:
raise ValueError(f"이미 등록된 사용자입니다: {email}")
now = _now_iso()
final_role = "admin" if is_super else role
rec = {
"email": email,
"name": name or email.split("@")[0],
"picture": "",
"role": final_role,
"modules": self._normalize_modules(
modules, is_admin=(final_role == "admin")
),
"created_at": now,
"last_login": "",
}
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
email = email.lower().strip()
is_super = email == SUPER_ADMIN_EMAIL
with self._lock:
data = self._read()
now = _now_iso()
existing = data["users"].get(email)
if existing is None:
rec = {
"email": email,
"name": name,
"picture": picture,
"role": "admin" if is_super else "user",
"modules": self._default_modules(is_admin=is_super),
"created_at": now,
"last_login": now,
}
else:
rec = dict(existing)
rec["name"] = name or rec.get("name", email)
rec["picture"] = picture or rec.get("picture", "")
rec["last_login"] = now
# 슈퍼 관리자는 항상 admin + 모든 모듈
if is_super:
rec["role"] = "admin"
rec["modules"] = self._default_modules(is_admin=True)
else:
rec["modules"] = self._normalize_modules(
rec.get("modules"), is_admin=(rec.get("role") == "admin")
)
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def update_permissions(
self, *, email: str, role: str | None, modules: dict[str, Any] | None
) -> dict[str, Any]:
"""관리자가 다른 사용자의 권한을 수정."""
email = email.lower().strip()
if email == SUPER_ADMIN_EMAIL:
raise PermissionError("슈퍼 관리자 계정은 수정할 수 없습니다.")
if role is not None and role not in ("admin", "user"):
raise ValueError(f"role 값이 잘못되었습니다: {role}")
with self._lock:
data = self._read()
rec = data["users"].get(email)
if rec is None:
raise KeyError(f"사용자를 찾을 수 없습니다: {email}")
if role is not None:
rec["role"] = role
new_is_admin = rec.get("role") == "admin"
rec["modules"] = self._normalize_modules(
modules if modules is not None else rec.get("modules"),
is_admin=new_is_admin,
)
data["users"][email] = rec
self._write_atomic(data)
return self._enrich(email, rec)
def delete(self, email: str) -> None:
email = email.lower().strip()
if email == SUPER_ADMIN_EMAIL:
raise PermissionError("슈퍼 관리자 계정은 삭제할 수 없습니다.")
with self._lock:
data = self._read()
if email in data["users"]:
del data["users"][email]
self._write_atomic(data)
@staticmethod
def _enrich(email: str, rec: dict[str, Any]) -> dict[str, Any]:
out = dict(rec)
out["email"] = email
out["is_super_admin"] = email == SUPER_ADMIN_EMAIL
out["modules"] = UserStore._normalize_modules(
out.get("modules"), is_admin=(out.get("role") == "admin")
)
return out
def is_admin(user_rec: dict[str, Any] | None) -> bool:
return bool(user_rec and user_rec.get("role") == "admin")
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
if not user_rec:
return False
# 슈퍼관리자는 항상 전체 접근. 일반 admin 은 칩(modules) 상태를 따른다 —
# 슈퍼관리자가 admin 의 개별 프로그램 접근을 제어할 수 있도록.
if user_rec.get("is_super_admin"):
return True
mods = user_rec.get("modules") or {}
if mods.get(module):
return True
# 승인자(<module>_approver) 권한이 있으면 해당 모듈 접근도 허용 —
# 본인 경비 등록/제출도 가능해야 하므로.
if module in APPROVER_KEYS:
return False
if mods.get(f"{module}_approver"):
return True
return False
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
return has_module(user_rec, f"{kind}_approver")
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
return {m for m in MODULE_KEYS if has_module(user_rec, m)}