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:
2026-08-14 00:23:02 +09:00
parent 31eab0d4cb
commit c6fb8ed375
29 changed files with 2689 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
-- =====================================================================
-- cafe24_db 초기화 스크립트 (PostgreSQL) — 카페24 상품 상세페이지 관리
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "cafe24_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/cafe24_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build web
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - OAuth 토큰(access/refresh)은 애플리케이션에서 Fernet 으로 암호화한 뒤
-- 저장한다. 키는 .env 의 CAFE24_TOKEN_SECRET. 이 DB 에 평문 토큰은 없다.
-- - cafe24_api_logs 에는 Authorization 헤더/토큰/client_secret 을 절대
-- 기록하지 않는다(엔드포인트·상태코드·소요시간·오류메시지만).
-- - 향후 카페24 주문관리 모듈도 이 DB(특히 cafe24_oauth_tokens)를 재사용한다.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE cafe24_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cafe24_db')
\gexec
-- 앱 전용 로그인 역할
SELECT 'CREATE ROLE cafe24_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cafe24_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE cafe24_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE cafe24_db TO cafe24_app;
-- cafe24_db 컨텍스트로 전환
\connect cafe24_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION cafe24_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) OAuth 토큰 (쇼핑몰 1개당 1행)
-- access_token / refresh_token 은 Fernet 암호문(TEXT)으로 저장한다.
-- 상품관리 + 향후 주문관리가 공유한다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_oauth_tokens (
id BIGSERIAL PRIMARY KEY,
mall_id TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scopes TEXT NOT NULL DEFAULT '',
last_refreshed_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
connected_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_cafe24_oauth_tokens_updated ON cafe24_oauth_tokens;
CREATE TRIGGER trg_cafe24_oauth_tokens_updated
BEFORE UPDATE ON cafe24_oauth_tokens
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 상품 캐시 (source of truth 는 언제나 Cafe24. 목록/검색 속도용)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_products (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL UNIQUE,
product_code TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
display BOOLEAN NOT NULL DEFAULT TRUE,
selling BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);
DROP TRIGGER IF EXISTS trg_cafe24_products_updated ON cafe24_products;
CREATE TRIGGER trg_cafe24_products_updated
BEFORE UPDATE ON cafe24_products
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
-- revision_type:
-- SYNC Cafe24 에서 읽어온 현재값 스냅샷
-- DRAFT 저장만 한 초안(미적용)
-- BACKUP Cafe24 에 쓰기 직전 자동 백업 ← 복원 기준
-- MANUAL 즉시 적용한 내용
-- SCHEDULED 예약으로 적용한 내용
-- ROLLBACK 과거 버전을 되돌린 내용
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_revisions (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
html_content TEXT NOT NULL DEFAULT '',
revision_type TEXT NOT NULL DEFAULT 'DRAFT',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_cafe24_revision_type CHECK (
revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK')
)
);
CREATE INDEX IF NOT EXISTS idx_cafe24_revisions_product
ON cafe24_product_revisions (product_no, created_at DESC, id DESC);
-- ════════════════════════════════════════════════════════════
-- 4) 예약 작업
-- 한 상품 = 한 행. 일괄 예약은 parent_job_id 로 묶되 행은 개별이므로
-- 한 상품 실패가 나머지를 막지 않는다.
-- end_at/end_action: 프로모션 종료 후 자동 복원용.
-- restore = 적용 직전 BACKUP(restore_revision_id)으로 되돌림
-- revision = end_revision_id 를 적용
-- restore_revision_id 는 예약 "실행 순간" Cafe24 에서 다시 읽어 만든
-- BACKUP revision 을 가리킨다(예약 생성 시점 값이 아님).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_schedules (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE RESTRICT,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
restore_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
end_at TIMESTAMPTZ,
end_action TEXT NOT NULL DEFAULT '',
end_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
parent_job_id TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CONSTRAINT chk_cafe24_schedule_status CHECK (
status IN ('PENDING','PROCESSING','SUCCESS','FAILED','CANCELLED')
),
CONSTRAINT chk_cafe24_schedule_end_action CHECK (
end_action IN ('','restore','revision')
)
);
-- worker 의 due 조회 인덱스 (PENDING + 시간순)
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_due
ON cafe24_product_schedules (scheduled_at)
WHERE status = 'PENDING';
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_product
ON cafe24_product_schedules (product_no, scheduled_at DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_parent
ON cafe24_product_schedules (parent_job_id);
DROP TRIGGER IF EXISTS trg_cafe24_schedules_updated ON cafe24_product_schedules;
CREATE TRIGGER trg_cafe24_schedules_updated
BEFORE UPDATE ON cafe24_product_schedules
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 작업 감사 로그 (누가 무엇을 바꿨나)
-- worker 가 수행한 작업은 actor='SCHEDULER'.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_audit_logs (
id BIGSERIAL PRIMARY KEY,
actor TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT '',
product_no BIGINT,
revision_id BIGINT,
schedule_id BIGINT,
result TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_created ON cafe24_audit_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_product ON cafe24_audit_logs (product_no, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 6) Cafe24 API 호출 로그 (실패 분석용 최소 정보)
-- ⚠️ Authorization 헤더 / access_token / refresh_token / client_secret 은
-- 절대 저장하지 않는다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_api_logs (
id BIGSERIAL PRIMARY KEY,
endpoint TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
product_no BIGINT,
http_status INTEGER,
result TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_created ON cafe24_api_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_status ON cafe24_api_logs (http_status);
-- ════════════════════════════════════════════════════════════
-- 7) 권한 (cafe24_app: CRUD only, DDL 없음)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cafe24_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
cafe24_oauth_tokens, cafe24_products, cafe24_product_revisions,
cafe24_product_schedules, cafe24_audit_logs, cafe24_api_logs
TO cafe24_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO cafe24_app;
SELECT 'cafe24_db ready' AS status;